feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-07-28 19:39:12 +02:00
1 parent 9f0969ec22
commit 315520beca
116 files changed
+2499 -586

No files matched your search

+57 -22
View File
@@ -9,9 +9,9 @@ tags:
- Layout
---
import iconColorImage from './img/icon-color.png'
import itemRadiusImage from './img/item-radius.png'
import layoutLargeImage from './img/layout-large.png'
import iconColorImage from "./img/icon-color.png";
import itemRadiusImage from "./img/item-radius.png";
import layoutLargeImage from "./img/layout-large.png";
Each dashboard is called a "board".
You can create as many boards as you want.
@@ -22,24 +22,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
Names of boards must be unique because this will be used in the URL to load the desired board.
## Create a new board
When creating a board, the availability of the board name will be checked for you.
![](./img/create-board-modal.png)
### Name
The name must not include any special characters and must be at least one character long.
Underscores and hyphen-minus are allowed.
### Column count
Homarr has a complex drag and drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
You navigate to the board settings by clicking on these three dots:
![](./img/navigate-to-settings.png)
@@ -49,7 +54,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic
![](./img/navigate-to-settings-2.png)
### General
#### Page title
This is the title that will be visible at the top left in the navigation bar:
![](./img/page-title.png)
@@ -57,6 +64,7 @@ This is the title that will be visible at the top left in the navigation bar:
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
#### Meta title
This is the secondary title of the page that is not directly visible in the page itself.
Browser will use it to set the title of tabs:
@@ -65,10 +73,12 @@ Browser will use it to set the title of tabs:
Homarr will fall back to the board name and a suffix if nothing is set.
#### Logo image URL
This is the URL that is used for the image at the top left.
Homarr will fall back to the Homarr logo if nothing is set.
#### Favicon image URL
This is the URL that is used for the favicon in the tab title.
Homarr will fall back to the Homarr logo if nothing is set.
@@ -92,41 +102,55 @@ App items use compact tiles. Widgets can opt into a compact summary, and larger
To rearrange, add, remove, resize, or configure board items, open the board on a larger screen.
### Background
#### Background image URL
Homarr can display a background image behind apps and widgets.
This URL can be used to define what image should be shown.
#### Background image attachment
This defines whether the background should move when the user scrolls.
#### Background image size
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
#### Background image repeat
This defines whether the background should be repeated if it cannot cover the entire page.
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
![](./img/background-repeat.png)
### Appearance
#### Primary color
Color that can be chosen freely to give accents to active elements.
#### Secondary color
Color that can be chosen freely to give accents.
#### Opacity
Opacity of the colors.
#### Icon color
Change the color of your bookmark / app icons.
<img src={iconColorImage} height={100} />
<img src={iconColorImage} height={100} />
#### Item radius
Change the radius of your items / categories.
<img src={itemRadiusImage} height={100} />
<img src={itemRadiusImage} height={100} />
### Custom CSS
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
@@ -135,14 +159,16 @@ The styling will only be applied on the respective board page.
### Behaviour
#### Disable app status
This will disable the status check for apps.
### Access control
With the access control, you can permit a single user or a group to your board.
The users and groups can have one of these three levels of access:
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|----------------------|------------|-----------|----------|--------------------------|
| -------------------- | ---------- | --------- | -------- | ------------------------ |
| View board | X | | | |
| Modify board | X | X | X | |
| Full access | X | X | X | X |
@@ -161,18 +187,23 @@ After editing permissions, the save button below the table must be pressed.
Otherwise, all changes will be discarded.
### Danger zone
#### Rename
Renaming a board will break all existing links to it permanently with no redirect.
#### Board visibility
By default, boards will be private. This means only authenticated users that are authorized have access.
Making a board public will expose it to everyone - including unauthenticated users.
#### Delete
This will permanently delete the board.
This action is irreversible and cannot be undone.
## Delete boards
The deletion of boards is permanent and cannot be undone.
There are two ways to delete a board:
@@ -184,7 +215,7 @@ Both actions will execute the same deletion process.
## Duplicate boards
:::note
Duplicating a board will not copy the users and groups that have access to the board
Duplicating a board will not copy the users and groups that have access to the board
nor the integrations the user does not have access to.
:::
@@ -194,47 +225,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
![](./img/duplicate-board-modal.png)
## Setting boards as your home board
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
All the following paths will lead to the home board:
- ``my-homarr.com``
- ``my-homarr.com/boards``
- `my-homarr.com`
- `my-homarr.com/boards`
Your home board will have a special small badge at the top right in the list of boards:
![](./img/home-board-flag.png)
### For yourself
To set the home board yourself, the easiest option is to set it directly in the list of boards:
![](./img/set-as-home-board-menu.png)
Alternatively, you can also define it via your user profile:
1. Click on your user profile at the top right
2. Click on ``Your preferences``
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
2. Click on `Your preferences`
3. Change the home board in the `home board` dropdown and click on `Save`.
### For other users
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
1. Navigate to the users: `Management` > `Users` > `Manage`
2. Click on the username of the user that you want to adjust
3. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-other-user.png)
3. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-other-user.png)
### For user groups
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
2. Go to tab ``Settings``
3. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-user-group.png)
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
2. Go to tab `Settings`
3. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-user-group.png)
### For whole server
You can also set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
2. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-global.png)
1. Navigate to the settings: `Management` > `Settings` > `Boards`
2. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-global.png)
:::note
For the global home board you can only select boards that are public.
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
@@ -14,6 +15,7 @@ import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
@@ -27,6 +29,7 @@ import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions";
import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy";
const logger = createLogger({ module: "createBoardContentPage" });
@@ -51,7 +54,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const resolvedParams = await params;
const queryClient = getQueryClient();
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]);
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -65,17 +69,19 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const [integrations] = await Promise.all([
getIntegrationsWithPermissionsAsync(session),
...Array.from(itemsMap).map(([kind, items]) =>
prefetchForKindAsync(kind, queryClient, items).catch((error) => {
logger.error(
new ErrorWithMetadata(
"Failed to prefetch widget",
{ widgetKind: kind, itemCount: items.length },
{ cause: error },
),
);
}),
),
...Array.from(itemsMap)
.filter(([kind]) => shouldPrefetchWidgetForRequest(kind, deviceType))
.map(([kind, items]) =>
prefetchForKindAsync(kind, queryClient, items).catch((error) => {
logger.error(
new ErrorWithMetadata(
"Failed to prefetch widget",
{ widgetKind: kind, itemCount: items.length },
{ cause: error },
),
);
}),
),
]);
const userId = session?.user.id ?? "anonymous";
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core";
@@ -47,6 +47,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { createMobileBoardElements } from "~/components/board/mobile/mobile-layout";
import { focusMobileBoardSection } from "~/components/board/mobile/mobile-section-navigation";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
@@ -268,6 +269,7 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
const tProfile = useScopedI18n("common.userAvatar.menu");
const reduceMotion = useReducedMotion();
const [opened, disclosure] = useDisclosure(false);
const pendingSectionAnchorId = useRef<string | null>(null);
const desktopLayout = getDesktopLayout(board);
const sections = useMemo(
() => createMobileBoardElements(board, desktopLayout.id).filter((element) => element.type === "sectionHeading"),
@@ -275,12 +277,16 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
);
const jumpToSection = (anchorId: string) => {
pendingSectionAnchorId.current = anchorId;
disclosure.close();
requestAnimationFrame(() => {
const heading = document.getElementById(anchorId);
heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
heading?.focus({ preventScroll: true });
});
};
const focusPendingSection = () => {
const anchorId = pendingSectionAnchorId.current;
pendingSectionAnchorId.current = null;
if (!anchorId) return;
focusMobileBoardSection({ anchorId, reduceMotion });
};
return (
@@ -296,8 +302,53 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
size="auto"
padding="md"
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
onExitTransitionEnd={focusPendingSection}
styles={{
content: {
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
}}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
{sections.length >= 2 && (
<>
<Group gap="xs" px="sm" pt="xs">
<IconList size={18} aria-hidden />
<Text size="sm" fw={600}>
{t("board.mobile.sections")}
</Text>
</Group>
{sections.map((section) => (
<Button
key={section.id}
variant="subtle"
size="lg"
justify="flex-start"
h="auto"
mih={44}
py="xs"
title={section.title}
style={{ marginInlineStart: (section.headingLevel - 2) * 12 }}
styles={{
label: {
whiteSpace: "normal",
overflowWrap: "anywhere",
textAlign: "start",
},
}}
onClick={() => jumpToSection(section.anchorId)}
>
{section.title}
</Button>
))}
<Divider />
</>
)}
<Button
component={Link}
href="/boards"
@@ -373,28 +424,6 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
{t("item.menu.label.settings")}
</Button>
)}
{sections.length >= 2 && (
<>
{showSettings && <Divider />}
<Group gap="xs" px="sm" pt="xs">
<IconList size={18} aria-hidden />
<Text size="sm" fw={600}>
{t("board.mobile.sections")}
</Text>
</Group>
{sections.map((section) => (
<Button
key={section.id}
variant="subtle"
size="lg"
justify="flex-start"
onClick={() => jumpToSection(section.anchorId)}
>
{section.title}
</Button>
))}
</>
)}
</Stack>
</Drawer>
</>
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy";
describe("shouldPrefetchWidgetForRequest", () => {
test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => {
expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false);
});
test("keeps downloads prefetched for desktop requests", () => {
expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true);
});
test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => {
expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true);
});
});
@@ -0,0 +1,7 @@
import type { WidgetKind } from "@homarr/definitions";
export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => {
const isMobileDevice = deviceType === "mobile" || deviceType === "tablet";
return kind !== "downloads" || !isMobileDevice;
};
@@ -8,12 +8,22 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { BoardLogo } from "~/components/layout/logo/board-logo";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
export const BoardSwitcherLogo = () => {
const board = useRequiredBoard();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const t = useI18n();
const isMobileBoard = useIsMobileBoard();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isMobileBoard });
if (!isMobileBoard) {
return (
<UnstyledButton component={Link} href="/">
<BoardLogoWithTitle size="md" hideTitleOnMobile />
</UnstyledButton>
);
}
return (
<Menu position="bottom-start" width={280}>
@@ -26,7 +36,7 @@ export const BoardSwitcherLogo = () => {
>
<Group gap="xs" wrap="nowrap">
<BoardLogo size={28} />
<Text fw={600} truncate>
<Text fw={600} truncate title={board.name}>
{board.name}
</Text>
<IconChevronDown size={16} aria-hidden />
@@ -46,7 +56,9 @@ export const BoardSwitcherLogo = () => {
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
aria-current={isCurrent ? "page" : undefined}
>
{availableBoard.name}
<Text truncate title={availableBoard.name}>
{availableBoard.name}
</Text>
</Menu.Item>
);
})}
@@ -25,6 +25,7 @@ interface BoardItemContentProps {
item: SectionItem;
displayMode?: WidgetDisplayMode;
disableContextMenu?: boolean;
isReadOnly?: boolean;
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
}
@@ -38,6 +39,7 @@ export const BoardItemContent = ({
item,
displayMode = "default",
disableContextMenu = false,
isReadOnly = false,
widgetStateRef: externalWidgetStateRef,
}: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
@@ -69,6 +71,7 @@ export const BoardItemContent = ({
width={width}
height={height}
displayMode={displayMode}
isReadOnly={isReadOnly}
widgetStateRef={widgetStateRef}
/>
</Card>
@@ -87,9 +90,8 @@ export const BoardItemContent = ({
<Badge
pos="absolute"
// It's 4 because of the mantine-react-table that has z-index 3
style={{ zIndex: 4 }}
style={{ zIndex: 4, insetInlineStart: 16 }}
top={2}
left={16}
size="xs"
radius={board.itemRadius}
styles={{
@@ -113,10 +115,11 @@ interface InnerContentProps {
width: number;
height: number;
displayMode: WidgetDisplayMode;
isReadOnly: boolean;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => {
const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -141,7 +144,7 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) =
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
@@ -154,21 +157,23 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) =
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} />
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} />}
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
isReadOnly={isReadOnly}
setOptions={(partialNewOptions) => {
if (isReadOnly) return;
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
});
}}
displayMode={displayMode}
{...dimensions}
/>
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item";
describe("getMobilePreloadRootMargin", () => {
test.each([
[568, "1136px 0px"],
[844, "1688px 0px"],
[932, "1864px 0px"],
])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => {
expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected);
});
test("keeps the preload policy explicit and prevents negative margins", () => {
expect(mobilePreloadViewportMultiplier).toBe(2);
expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px");
});
});
@@ -8,14 +8,25 @@ import classes from "./mobile-board.module.css";
interface DeferredMobileItemProps extends PropsWithChildren {
eager: boolean;
unmountWhenOffscreen: boolean;
}
export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => {
export const mobilePreloadViewportMultiplier = 2;
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => {
const [hasMounted, setHasMounted] = useState(eager);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hasMountedOnceRef = useRef(eager);
useEffect(() => {
if (eager || hasMounted) return;
if (eager) {
hasMountedOnceRef.current = true;
setHasMounted(true);
return;
}
const element = wrapperRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
@@ -23,17 +34,37 @@ export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps)
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setHasMounted(true);
observer.disconnect();
},
{ rootMargin: "200% 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [eager, hasMounted]);
let observer: IntersectionObserver | null = null;
const observe = () => {
if (hasMountedOnceRef.current && !unmountWhenOffscreen) return;
observer?.disconnect();
observer = new IntersectionObserver(
([entry]) => {
const isNearViewport = entry?.isIntersecting ?? false;
if (isNearViewport) {
hasMountedOnceRef.current = true;
setHasMounted(true);
if (!unmountWhenOffscreen) observer?.disconnect();
return;
}
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
setHasMounted(false);
}
},
{ rootMargin: getMobilePreloadRootMargin(window.innerHeight) },
);
observer.observe(element);
};
observe();
window.addEventListener("resize", observe);
return () => {
window.removeEventListener("resize", observe);
observer?.disconnect();
};
}, [eager, unmountWhenOffscreen]);
return (
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
@@ -6,6 +6,18 @@
overflow-x: clip;
}
.boardTitle {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
@@ -18,8 +30,6 @@
position: relative;
min-width: 0;
min-height: 0;
content-visibility: auto;
contain-intrinsic-size: auto 12rem;
}
.deferredItem {
@@ -34,9 +44,18 @@
}
.sectionHeading {
width: fit-content;
max-width: 100%;
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
padding-block: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-xs);
outline-offset: 4px;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.empty {
@@ -48,14 +67,21 @@
position: absolute;
z-index: 11;
top: 4px;
right: 4px;
inset-inline-end: 4px;
border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent);
box-shadow: var(--mantine-shadow-sm);
}
.detailContent,
.detailContent > :global(.grid-stack-item-content) {
position: relative;
width: 100%;
height: 100%;
min-height: 24rem;
}
.detailContent :global(.mantine-ActionIcon-root),
.detailContent :global(.mantine-Button-root) {
min-width: 44px;
min-height: 44px;
}
@@ -13,6 +13,7 @@ import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { useBoardPermissions } from "../permissions/client";
import { SectionProvider } from "../sections/section-context";
import { DeferredMobileItem } from "./deferred-mobile-item";
import classes from "./mobile-board.module.css";
@@ -70,19 +71,16 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
data-mobile-board-item={item.id}
data-mobile-display-mode={presentation.displayMode}
>
<DeferredMobileItem eager={presentation.eager}>
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
<BoardItemContent
item={item}
displayMode={presentation.displayMode}
disableContextMenu
widgetStateRef={widgetStateRef}
/>
<MobileWidgetActions
item={item}
supportsDetails={presentation.supportsDetails}
isReadOnly
widgetStateRef={widgetStateRef}
/>
</DeferredMobileItem>
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
</Box>
);
};
@@ -90,6 +88,7 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
export const MobileBoard = () => {
const board = useRequiredBoard();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]);
@@ -110,6 +109,9 @@ export const MobileBoard = () => {
}}
>
<Box className={classes.stream} data-mobile-board>
<Title order={1} className={classes.boardTitle}>
{board.name}
</Title>
{elements.length === 0 ? (
<Center className={classes.empty}>
<Stack align="center" gap="xs" ta="center" maw={360}>
@@ -120,7 +122,7 @@ export const MobileBoard = () => {
{t("board.mobile.empty.title")}
</Title>
<Text c="dimmed" size="sm">
{t("board.mobile.empty.description")}
{t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
</Text>
</Stack>
</Center>
@@ -130,10 +132,12 @@ export const MobileBoard = () => {
<Title
key={block.id}
id={block.anchorId}
order={2}
size="h4"
order={block.headingLevel}
size={block.headingLevel === 2 ? "h4" : block.headingLevel === 3 ? "h5" : "h6"}
fw={block.headingLevel === 2 ? 700 : 600}
className={classes.sectionHeading}
tabIndex={-1}
style={{ marginInlineStart: Math.max(0, block.headingLevel - 2) * 12 }}
>
{block.title}
</Title>
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { hasOpenDialogOutside } from "./mobile-dialog-stack";
describe("hasOpenDialogOutside", () => {
test("detects an open child dialog outside the details portal", () => {
const root = document.createElement("div");
const details = document.createElement("div");
const child = document.createElement("div");
details.setAttribute("role", "dialog");
child.setAttribute("role", "dialog");
root.append(details, child);
expect(hasOpenDialogOutside(details, root)).toBe(true);
});
test("ignores hidden and closed dialogs", () => {
const root = document.createElement("div");
const details = document.createElement("div");
const hiddenChild = document.createElement("div");
details.setAttribute("role", "dialog");
hiddenChild.setAttribute("role", "dialog");
hiddenChild.setAttribute("aria-hidden", "true");
root.append(details, hiddenChild);
expect(hasOpenDialogOutside(details, root)).toBe(false);
});
test("does not treat the current details dialog as nested", () => {
const details = document.createElement("div");
details.setAttribute("role", "dialog");
expect(hasOpenDialogOutside(details, details)).toBe(false);
});
});
@@ -0,0 +1,47 @@
"use client";
import type { RefObject } from "react";
import { useEffect, useState } from "react";
const getDialogElement = (element: HTMLElement | null) => {
if (!element) return null;
if (element.matches('[role="dialog"]')) return element;
return element.closest<HTMLElement>('[role="dialog"]') ?? element.querySelector<HTMLElement>('[role="dialog"]');
};
const isOpenDialog = (element: Element) =>
element.getAttribute("aria-hidden") !== "true" &&
element.getAttribute("data-hidden") !== "true" &&
element.getAttribute("data-state") !== "closed" &&
!element.hasAttribute("hidden");
export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => {
const currentDialog = getDialogElement(element);
return Array.from(root.querySelectorAll('[role="dialog"]')).some(
(dialog) => dialog !== currentDialog && isOpenDialog(dialog),
);
};
export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject<HTMLDivElement | null>) => {
const [hasNestedDialog, setHasNestedDialog] = useState(false);
useEffect(() => {
if (!opened || typeof MutationObserver === "undefined") return;
const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current));
update();
const observer = new MutationObserver(update);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"],
});
return () => observer.disconnect();
}, [dialogRef, opened]);
return opened && hasNestedDialog;
};
@@ -147,7 +147,10 @@ describe("createMobileBoardItems", () => {
type: "sectionHeading",
sectionId: category.id,
anchorId: getMobileSectionAnchorId(category.id),
headingLevel: 2,
});
expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 });
expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 });
});
test("omits headings for empty and unnamed sections while preserving their items", () => {
@@ -180,4 +183,31 @@ describe("createMobileBoardItems", () => {
expect(result.map((element) => element.type)).toEqual(["item", "item"]);
expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]);
});
test("does not skip heading levels when unnamed sections are flattened", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build();
const namedDynamicSection = new DynamicSectionMockBuilder({
id: "dynamic",
options: { title: "Services", borderColor: "", customCssClasses: [] },
})
.addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id })
.build();
board.sections.push(unnamedCategory, namedDynamicSection);
board.items.push(
new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id })
.build(),
);
expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({
type: "sectionHeading",
title: "Services",
headingLevel: 2,
});
});
});
@@ -19,6 +19,7 @@ export type MobileBoardSectionHeading = {
sectionId: string;
title: string;
anchorId: string;
headingLevel: 2 | 3 | 4 | 5 | 6;
};
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
@@ -51,7 +52,10 @@ const getLayout = <TLayout extends PositionedLayout>(
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => {
const createSectionHeading = (
section: CategorySection | DynamicSectionItem,
headingDepth: number,
): MobileBoardSectionHeading | null => {
const title = (section.kind === "category" ? section.name : section.options.title).trim();
if (title.length === 0) return null;
@@ -61,6 +65,7 @@ const createSectionHeading = (section: CategorySection | DynamicSectionItem): Mo
sectionId: section.id,
title,
anchorId: getMobileSectionAnchorId(section.id),
headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"],
};
};
@@ -112,16 +117,18 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
const createSectionElements = (
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
headingDepth = 0,
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
if (visitedSections.has(section.id)) return { elements: [], items: [] };
const sectionId = section.id;
visitedSections.add(sectionId);
const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth);
const sectionElements: MobileBoardElement[] = [];
const sectionItems: SectionItem[] = [];
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
if (element.type === "section") {
const nestedResult = createSectionElements(element);
const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth);
sectionElements.push(...nestedResult.elements);
sectionItems.push(...nestedResult.items);
continue;
@@ -136,7 +143,6 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
if (sectionItems.length === 0) return { elements: [], items: [] };
const heading = section.kind === "empty" ? null : createSectionHeading(section);
return {
elements: heading ? [heading, ...sectionElements] : sectionElements,
items: sectionItems,
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { resolveMobileItemPresentation } from "./mobile-presentation";
import {
isMobileContextActionVisible,
resolveMobileItemPresentation,
shouldRenderMobileWidgetActions,
} from "./mobile-presentation";
describe("resolveMobileItemPresentation", () => {
test("renders apps as eager compact tiles", () => {
@@ -9,19 +13,51 @@ describe("resolveMobileItemPresentation", () => {
height: 1,
eager: true,
supportsDetails: false,
unmountWhenOffscreen: false,
});
});
test("defaults widgets to full width and clamps desktop height", () => {
test("defaults widgets to full width without inventing a detail view", () => {
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
width: 2,
height: 3,
eager: false,
supportsDetails: false,
unmountWhenOffscreen: false,
});
});
test("enables details only when the widget explicitly supports them", () => {
expect(
resolveMobileItemPresentation(
{ kind: "calendar", height: 2 },
{
width: 2,
height: 1,
supportsDetailView: true,
},
),
).toMatchObject({
supportsDetails: true,
});
});
test("prefers explicit presentation metadata", () => {
test("keeps automatic footprint defaults when only details are configured", () => {
expect(
resolveMobileItemPresentation(
{ kind: "calendar", height: 7 },
{
supportsDetailView: true,
},
),
).toMatchObject({
width: 2,
height: 3,
supportsDetails: true,
});
});
test("prefers explicit presentation metadata while eager mounting takes precedence", () => {
expect(
resolveMobileItemPresentation(
{ kind: "clock", height: 3 },
@@ -31,6 +67,7 @@ describe("resolveMobileItemPresentation", () => {
supportsCompactSummary: true,
supportsDetailView: false,
eager: true,
unmountWhenOffscreen: true,
},
),
).toEqual({
@@ -39,6 +76,37 @@ describe("resolveMobileItemPresentation", () => {
displayMode: "mobileSummary",
supportsDetails: false,
eager: true,
unmountWhenOffscreen: false,
});
});
});
describe("shouldRenderMobileWidgetActions", () => {
test("does not render an action trigger without an explicit capability", () => {
expect(
shouldRenderMobileWidgetActions({
supportsDetails: false,
supportsRefresh: false,
visibleContextActionCount: 0,
}),
).toBe(false);
});
test.each([
{ supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 },
{ supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 },
{ supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 },
])("renders an action trigger for an explicit capability", (capabilities) => {
expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true);
});
});
describe("isMobileContextActionVisible", () => {
test("requires actions to opt into the read-only mobile surface", () => {
const action = { key: "configure", label: "Configure", onClick: () => undefined };
expect(isMobileContextActionVisible(action)).toBe(false);
expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true);
expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false);
});
});
@@ -1,5 +1,5 @@
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetMobilePresentation } from "@homarr/widgets";
import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets";
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
@@ -8,6 +8,20 @@ export const resolveMobileItemPresentation = (
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))),
displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const),
supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app",
supportsDetails: mobile?.supportsDetailView === true,
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
});
export const shouldRenderMobileWidgetActions = ({
supportsDetails,
supportsRefresh,
visibleContextActionCount,
}: {
supportsDetails: boolean;
supportsRefresh: boolean;
visibleContextActionCount: number;
}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
action.mobileVisible === true && !action.hidden;
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { focusMobileBoardSection } from "./mobile-section-navigation";
afterEach(() => {
document.body.replaceChildren();
});
describe("focusMobileBoardSection", () => {
test.each([
[false, "smooth"],
[true, "auto"],
] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => {
const heading = document.createElement("h2");
heading.id = "section";
heading.tabIndex = -1;
heading.scrollIntoView = vi.fn();
document.body.append(heading);
expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true);
expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" });
expect(document.activeElement).toBe(heading);
});
test("does nothing when the section no longer exists", () => {
expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false);
});
});
@@ -0,0 +1,13 @@
interface FocusMobileBoardSectionOptions {
anchorId: string;
reduceMotion: boolean;
}
export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => {
const heading = document.getElementById(anchorId);
if (!heading) return false;
heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
heading.focus({ preventScroll: true });
return true;
};
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject } from "react";
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
@@ -18,8 +18,9 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { useItemActions } from "../items/item-actions";
import { useHasNestedDialog } from "./mobile-dialog-stack";
import classes from "./mobile-board.module.css";
import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
interface MobileWidgetActionsProps {
item: SectionItem;
@@ -30,18 +31,16 @@ interface MobileWidgetActionsProps {
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
const actionTriggerRef = useRef<HTMLButtonElement | null>(null);
const detailsDialogRef = useRef<HTMLDivElement | null>(null);
const openDetailsAfterActionsRef = useRef(false);
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { updateItemOptions } = useItemActions();
const definition = widgetImports[item.kind].definition;
const queryClient = useQueryClient();
const queryKey = useMemo(
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
[definition, item.kind],
);
const isFetching = useIsFetching({ queryKey }) > 0;
const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, item.options, settings],
@@ -51,36 +50,32 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
return definition.contextActions({
options,
setOptions: (partial: Record<string, unknown>) =>
updateItemOptions({
itemId: item.id,
newOptions: { ...options, ...partial },
}),
setOptions: () => undefined,
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
} as never) as WidgetContextMenuAction[];
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
const visibleContextActions = contextActions.filter((action) => !action.hidden);
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]);
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
const hasActions = shouldRenderMobileWidgetActions({
supportsDetails,
supportsRefresh: queryKey !== undefined,
visibleContextActionCount: visibleContextActions.length,
});
if (!hasActions) return null;
const refresh = () => {
void queryClient.refetchQueries({ queryKey, type: "all" });
actionsDisclosure.close();
};
return (
<>
<ActionIcon
ref={actionTriggerRef}
className={classes.widgetAction}
variant="filled"
color="dark"
radius="xl"
size={44}
aria-label={t("board.mobile.actions")}
aria-label={t("board.mobile.actions", { title })}
onClick={actionsDisclosure.open}
>
<IconDotsVertical size={20} />
@@ -94,6 +89,21 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
size="auto"
padding="md"
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
styles={{
content: {
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
}}
onExitTransitionEnd={() => {
if (!openDetailsAfterActionsRef.current) return;
openDetailsAfterActionsRef.current = false;
detailsDisclosure.open();
}}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
{supportsDetails && (
@@ -103,8 +113,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
size="lg"
leftSection={<IconMaximize size={20} />}
onClick={() => {
openDetailsAfterActionsRef.current = true;
actionsDisclosure.close();
detailsDisclosure.open();
}}
>
{t("board.mobile.details")}
@@ -130,37 +140,74 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
</Button>
);
})}
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={refresh}
>
{t("item.menu.label.refresh")}
</Button>
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
</Stack>
</Drawer>
<Modal
ref={detailsDialogRef}
opened={detailsOpened}
onClose={detailsDisclosure.close}
title={title}
fullScreen
padding="md"
returnFocus={false}
trapFocus={!hasNestedDialog}
closeOnEscape={!hasNestedDialog}
onExitTransitionEnd={() => actionTriggerRef.current?.focus()}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
styles={{
content: {
display: "flex",
flexDirection: "column",
height: "100dvh",
paddingTop: "env(safe-area-inset-top)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
body: {
height: "calc(100dvh - 4rem)",
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu widgetStateRef={widgetStateRef} />
<BoardItemContent
item={item}
displayMode="mobileDetail"
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
</Box>
</Modal>
</>
);
};
const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => {
const t = useI18n();
const queryClient = useQueryClient();
const isFetching = useIsFetching({ queryKey }) > 0;
return (
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={() => {
void queryClient.refetchQueries({ queryKey, type: "all" });
onComplete();
}}
>
{t("item.menu.label.refresh")}
</Button>
);
};
@@ -6,11 +6,15 @@ import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { useIsMobileBoard } from "../../board/use-mobile-board";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
export const DesktopSearchInput = () => {
const t = useI18n();
const isMobileBoard = useIsMobileBoard();
if (isMobileBoard) return null;
return (
<TextInput
@@ -29,9 +33,14 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => {
const t = useI18n();
const isMobileBoard = useIsMobileBoard();
return (
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch} aria-label={t("search.placeholder")}>
<HeaderButton
onClick={openSpotlight}
className={isMobileBoard ? undefined : classes.mobileSearch}
aria-label={t("search.placeholder")}
>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
);
+243
View File
@@ -0,0 +1,243 @@
import { chromium, devices, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import SuperJSON from "superjson";
import { describe, test } from "vitest";
import { eq } from "@homarr/db";
import * as sqliteSchema from "@homarr/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { SqliteDatabase } from "./shared/e2e-db";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
const boardName = "mobile-e2e";
const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => {
const boardId = createId();
const layoutId = createId();
const firstSectionId = createId();
const secondSectionId = createId();
await db.insert(sqliteSchema.boards).values({
id: boardId,
name: boardName,
creatorId: userId,
});
await db.insert(sqliteSchema.layouts).values({
id: layoutId,
name: "Desktop",
boardId,
columnCount: 12,
breakpoint: 0,
});
await db.insert(sqliteSchema.sections).values([
{
id: firstSectionId,
boardId,
kind: "category",
name: "First section",
xOffset: 0,
yOffset: 0,
},
{
id: secondSectionId,
boardId,
kind: "category",
name: "Second section",
xOffset: 0,
yOffset: 20,
},
]);
const itemRows = Array.from({ length: 36 }, (_, index) => {
const id = createId();
return {
id,
boardId,
kind: index === 0 ? ("notebook" as const) : ("clock" as const),
options: SuperJSON.stringify(index === 0 ? { content: "<p>Mobile notes</p>" } : {}),
advancedOptions: SuperJSON.stringify({}),
};
});
await db.insert(sqliteSchema.items).values(itemRows);
await db.insert(sqliteSchema.itemLayouts).values(
itemRows.map((item, index) => ({
itemId: item.id,
layoutId,
sectionId: index < 18 ? firstSectionId : secondSectionId,
xOffset: (index % 6) * 2,
yOffset: Math.floor((index % 18) / 6) * 2,
width: 2,
height: 2,
})),
);
await db
.update(sqliteSchema.users)
.set({
homeBoardId: boardId,
mobileHomeBoardId: boardId,
completedBoardTour: true,
})
.where(eq(sqliteSchema.users.id, userId));
};
describe("Automatic mobile board", () => {
test("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, adminCredentials);
await seedMobileBoardAsync(db, userId);
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const loginContext = await browser.newContext();
try {
const loginPage = await loginContext.newPage();
await loginPage.goto(`${baseUrl}/auth/login`);
await loginPage.getByLabel("Username").fill(adminCredentials.username);
await loginPage.locator("#password").fill(adminCredentials.password);
await loginPage.locator("button[type='submit']").click();
await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
const storageState = await loginContext.storageState();
const mobileViewports = [
{ name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true },
{ name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true },
{ name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true },
{
name: "phone landscape",
width: 844,
height: 390,
userAgent: devices["iPhone 13"].userAgent,
phone: true,
},
{
name: "narrow tablet",
width: 768,
height: 1024,
userAgent: devices["iPad (gen 7)"].userAgent,
phone: false,
},
];
for (const viewport of mobileViewports) {
const context = await browser.newContext({
storageState,
viewport: { width: viewport.width, height: viewport.height },
userAgent: viewport.userAgent,
hasTouch: true,
isMobile: viewport.phone,
reducedMotion: viewport.width === 390 ? "reduce" : "no-preference",
});
const page = await context.newPage();
await page.goto(`${baseUrl}/boards/${boardName}`);
await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 });
await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36);
await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0);
await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible();
const layoutMetrics = await page.evaluate(() => {
const grids = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board] > div"));
const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid");
const items = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]"));
const focusable = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-mobile-board] a[href], [data-mobile-board] button, [data-mobile-board] input, [data-mobile-board] select, [data-mobile-board] textarea, [data-mobile-board] [tabindex]:not([tabindex="-1"])',
),
);
return {
columns: twoColumnGrids.map(
(grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length,
),
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
overflowingItems: items.filter((item) => item.scrollWidth > item.clientWidth + 1).length,
outOfBoundsControls: focusable.filter((element) => {
const rect = element.getBoundingClientRect();
return rect.left < -1 || rect.right > window.innerWidth + 1;
}).length,
layoutControls: document.querySelectorAll(
'[data-testid*="layout"], button[aria-label*="resize" i], button[aria-label*="move" i]',
).length,
};
});
expect(
layoutMetrics.columns.length > 0 && layoutMetrics.columns.every((count) => count === 2),
viewport.name,
).toBe(true);
expect(layoutMetrics.documentOverflow, viewport.name).toBeLessThanOrEqual(0);
expect(layoutMetrics.overflowingItems, viewport.name).toBe(0);
expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0);
expect(layoutMetrics.layoutControls, viewport.name).toBe(0);
if (viewport.width === 320) {
await page.evaluate(() => {
document.documentElement.style.fontSize = "200%";
});
const zoomOverflow = await page.evaluate(() => ({
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
items: Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]")).filter(
(item) => item.scrollWidth > item.clientWidth + 1,
).length,
}));
expect(zoomOverflow.document).toBeLessThanOrEqual(0);
expect(zoomOverflow.items).toBe(0);
}
if (viewport.width === 390) {
await page.getByRole("button", { name: "More" }).click();
const moreDrawer = page.getByRole("dialog", { name: "More" });
await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible();
await moreDrawer.getByRole("button", { name: "Second section" }).click();
await expect(page.getByRole("heading", { name: "Second section" })).toBeFocused();
const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i });
await notebookActions.click();
await page.getByRole("button", { name: "Open widget details" }).click();
const details = page.getByRole("dialog", { name: "Notebook" });
await expect(details).toBeVisible();
await details.getByRole("button", { name: "Close" }).click();
await expect(notebookActions).toBeFocused();
const rtlOverflow = await page.evaluate(() => {
document.documentElement.dir = "rtl";
return document.documentElement.scrollWidth - document.documentElement.clientWidth;
});
expect(rtlOverflow).toBeLessThanOrEqual(0);
}
await context.close();
}
const desktopContext = await browser.newContext({
storageState,
viewport: { width: 800, height: 900 },
});
const desktopPage = await desktopContext.newPage();
await desktopPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0);
await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36);
await desktopContext.close();
} finally {
await loginContext.close();
await browser.close();
await homarrContainer.stop();
}
}, 180_000);
});
+4 -2
View File
@@ -1592,6 +1592,9 @@ const getHomeIdBoardAsync = async (
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
) => {
// Mobile home-board selection remains device-based because the server does
// not know the resolved CSS viewport. Tablet rendering is resolved against
// 48em on the client and must not be guessed here.
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (!user) {
@@ -1613,8 +1616,7 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
),
)
.orderBy(asc(groups.position))
+64 -3
View File
@@ -516,6 +516,64 @@ describe("getHomeBoard should return home board", () => {
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
test.each([
{ deviceType: "mobile", expectedBoard: "mobile-home" },
{ deviceType: "tablet", expectedBoard: "desktop-home" },
] as const)(
"should use the $expectedBoard preference for a $deviceType device",
async ({ deviceType, expectedBoard }) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
await db
.update(users)
.set({
homeBoardId: desktopBoardProps.boardId,
mobileHomeBoardId: mobileBoardProps.boardId,
})
.where(eq(users.id, defaultCreatorId));
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
name: expectedBoard,
});
},
);
test("should return a mobile-only group home board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "mobile group",
position: 1,
homeBoardId: null,
mobileHomeBoardId: mobileBoardProps.boardId,
});
await db.insert(groupMembers).values({
userId: defaultCreatorId,
groupId,
});
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
name: "group-mobile-home",
...mobileBoardProps,
});
});
test("should return global home board when user doesn't have one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -1427,9 +1485,12 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
await db.insert(users).values({
id: defaultCreatorId,
});
await db
.insert(users)
.values({
id: defaultCreatorId,
})
.onConflictDoNothing();
const boardId = createId();
await db.insert(boards).values({
+6 -25
View File
@@ -1,16 +1,13 @@
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
@@ -30,8 +27,9 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
@@ -44,25 +42,8 @@ export const calendarRouter = createTRPCRouter({
kind: integration.kind,
},
};
}),
},
{ throwOnAllFailure: true },
);
return settled.flatMap((result, index) => {
if (result.status === "fulfilled") {
return [result.value];
}
const integration = ctx.integrations[index];
logger.warn(
new ErrorWithMetadata(
"Calendar integration request failed; skipping events for this integration",
{
integrationId: integration?.id,
integrationKind: integration?.kind,
},
{ cause: result.reason },
),
);
return [];
});
}),
});
+12 -8
View File
@@ -24,14 +24,18 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
},
{ throwOnAllFailure: true },
);
}),
pause: protectedProcedure
.meta({
@@ -27,15 +27,19 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
},
{ throwOnAllFailure: true },
);
}),
listStorageVolumes: publicProcedure
.meta({
@@ -9,20 +9,24 @@ export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
},
{ throwOnAllFailure: true },
);
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
@@ -25,10 +25,14 @@ export const mediaRequestsRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ throwOnAllFailure: true },
);
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
@@ -57,10 +61,14 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ throwOnAllFailure: true },
);
return {
stats: results.flatMap((result) => result.data.stats),
users: results
@@ -23,11 +23,15 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
},
{ throwOnAllFailure: true },
);
}),
});
@@ -13,19 +13,23 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
},
{ throwOnAllFailure: true },
);
}),
});
+15 -11
View File
@@ -8,17 +8,21 @@ export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
},
{ throwOnAllFailure: true },
);
}),
});
@@ -0,0 +1,43 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { settleIntegrationQueries } from "./settle-integrations";
const integrations = [
{ id: "first", name: "First", kind: "mock" },
{ id: "second", name: "Second", kind: "mock" },
];
describe("settleIntegrationQueries", () => {
it("retains successful integration results when another integration fails", async () => {
const results = await settleIntegrationQueries(
integrations,
vi.fn(async (integration) => {
if (integration.id === "first") throw new Error("offline");
return integration.name;
}),
{ throwOnAllFailure: true },
);
expect(results).toEqual(["Second"]);
});
it("throws when every integration fails and throwOnAllFailure is enabled", async () => {
await expect(
settleIntegrationQueries(
integrations,
vi.fn(async () => {
throw new Error("offline");
}),
{ throwOnAllFailure: true },
),
).rejects.toThrow("offline");
});
it("does not treat an empty integration list as a failure", async () => {
await expect(
settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }),
).resolves.toEqual([]);
});
});
+14 -1
View File
@@ -11,6 +11,7 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailure?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
@@ -19,7 +20,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
return settled.flatMap((result, index) => {
const results = settled.flatMap((result, index) => {
if (result.status === "fulfilled") return [result.value];
const integration = integrations[index];
logger.warn(
@@ -32,4 +33,16 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
return [];
});
const firstFailure = settled.find((result) => result.status === "rejected");
if (
options?.throwOnAllFailure === true &&
settled.length > 0 &&
settled.every((result) => result.status === "rejected") &&
firstFailure?.status === "rejected"
) {
throw firstFailure.reason;
}
return results;
}
+9 -3
View File
@@ -1853,6 +1853,8 @@
"internalServerError": "Failed to control DNS Hole"
},
"controls": {
"enable": "Enable",
"disable": "Disable",
"enableAll": "Enable All",
"disableAll": "Disable All",
"setTimer": "Set Timer",
@@ -3859,7 +3861,8 @@
}
},
"status": {
"unhealthy": "Unhealthy"
"unhealthy": "Unhealthy",
"free": "{value}% free"
}
},
"customApi": {
@@ -4137,15 +4140,18 @@
},
"board": {
"mobile": {
"actions": "Widget actions",
"actions": "Actions for {title}",
"details": "Open widget details",
"empty": {
"title": "This board is empty",
"description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically."
"editorDescription": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically.",
"viewerDescription": "No items have been added to this board yet."
},
"more": "More",
"profile": "Profile and account",
"sections": "Jump to section",
"dataWarning": "Data may be incomplete or outdated",
"systemStatus": "{up} up · {attention} need attention",
"currentBoard": "Current board"
},
"action": {
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
mobile: { supportsDetailView: true },
queryKey: [["widget", "anchorNotes"]],
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
+15 -6
View File
@@ -14,16 +14,21 @@ import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileLoading } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const { data: app } = clientApi.app.byId.useQuery({
const {
data: app,
error,
isPending,
} = clientApi.app.byId.useQuery({
id: options.appId,
});
useRegisterSpotlightContextResults(
@@ -48,10 +53,14 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, options.openInNewTab],
);
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !app) throw error;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
const isMobileSummary = displayMode === "mobileSummary";
const isTiny = !isMobileSummary && (height < 100 || width < 100);
const layout = isMobileSummary ? "column" : options.layout;
const isColumnLayout = layout.startsWith("column");
return (
<AppLink
@@ -76,7 +85,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
direction={layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
@@ -93,7 +102,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
{!isMobileSummary && options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
queryKey: [["widget", "archiveTeamWarrior"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
@@ -14,6 +14,8 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
mobile: { supportsDetailView: true },
queryKey: [["widget", "audioStats"]],
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
createOptions() {
@@ -13,6 +13,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
@@ -35,6 +36,7 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
displayMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -68,9 +70,16 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const failedResults = results.filter((result) => "error" in result);
if (alertsError) throw alertsError;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
if (isPending) {
if (displayMode === "mobileSummary") return <WidgetMobileLoading />;
return (
<Center h="100%">
<Loader size="sm" />
@@ -78,6 +87,17 @@ export default function BeszelAlertsWidget({
);
}
if (displayMode === "mobileSummary") {
return (
<WidgetMobileSummary
value={triggeredAlerts.length}
label={t("status.triggered")}
description={`${alerts.length} ${t("name")}`}
isStale={failedResults.length > 0}
/>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -6,6 +6,14 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -39,6 +39,7 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary";
interface SizeConfig {
iconSize: number;
@@ -358,7 +359,23 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
);
};
export default function BeszelSystemGridWidget({
export default function BeszelSystemGridWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
if (displayMode === "mobileSummary") {
return (
<BeszelSystemsMobileSummary
integrationIds={props.integrationIds}
statusFilter={props.options.statusFilter}
label={t("name")}
/>
);
}
return <BeszelSystemGridContent {...props} />;
}
function BeszelSystemGridContent({
options,
integrationIds,
isEditMode,
@@ -6,6 +6,13 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
@@ -15,9 +15,20 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
export default function BeszelSystemStatsWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
if (displayMode === "mobileSummary") {
return <BeszelSystemsMobileSummary integrationIds={props.integrationIds} statusFilter="all" label={t("name")} />;
}
return <BeszelSystemStatsContent {...props} />;
}
function BeszelSystemStatsContent({
options,
integrationIds,
isEditMode,
@@ -25,6 +36,7 @@ export default function BeszelSystemStatsWidget({
boardId,
itemId,
setOptions,
isReadOnly = false,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -41,18 +53,29 @@ export default function BeszelSystemStatsWidget({
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const [temporarySystemId, setTemporarySystemId] = useState<string | null>(null);
const [temporaryTimePeriod, setTemporaryTimePeriod] = useState<BeszelTimePeriod | null>(null);
const configuredSystem = temporarySystemId ?? options.systemId;
const selectedSystem =
isReadOnly && !systems.some((system) => system.value === configuredSystem)
? (systems[0]?.value ?? "")
: configuredSystem || systems[0]?.value || "";
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
if (isReadOnly) {
setTemporarySystemId(value);
return;
}
setOptions({ newOptions: { systemId: value } });
if (hasChangeAccess && boardId && itemId) {
saveItemOptions({ boardId, itemId, newOptions: { systemId: value } });
}
},
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
[setOptions, isReadOnly, hasChangeAccess, boardId, itemId, saveItemOptions],
);
if (systemsError) throw systemsError;
@@ -140,7 +163,7 @@ export default function BeszelSystemStatsWidget({
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={options.timePeriod as BeszelTimePeriod}
timePeriod={temporaryTimePeriod ?? (options.timePeriod as BeszelTimePeriod)}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
@@ -152,7 +175,13 @@ export default function BeszelSystemStatsWidget({
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
onSwitchToHistorical={() => {
if (isReadOnly) {
setTemporaryTimePeriod("1h");
return;
}
setOptions({ newOptions: { timePeriod: "1h" } });
}}
/>
</Stack>
</ScrollArea>
@@ -16,6 +16,13 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
@@ -38,6 +38,7 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -70,7 +71,23 @@ const getSizeConfig = (width: number): SizeConfig => {
};
};
export default function BeszelSystemTableWidget({
export default function BeszelSystemTableWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
if (displayMode === "mobileSummary") {
return (
<BeszelSystemsMobileSummary
integrationIds={props.integrationIds}
statusFilter={props.options.statusFilter}
label={t("name")}
/>
);
}
return <BeszelSystemTableContent {...props} />;
}
function BeszelSystemTableContent({
options,
integrationIds,
isEditMode,
@@ -25,6 +25,13 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
@@ -0,0 +1,44 @@
"use client";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import { useBeszelFilteredSystems } from "./hooks";
interface BeszelSystemsMobileSummaryProps {
integrationIds: string[];
label: string;
statusFilter: string;
}
export const BeszelSystemsMobileSummary = ({
integrationIds,
label,
statusFilter,
}: BeszelSystemsMobileSummaryProps) => {
const t = useI18n();
const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useBeszelFilteredSystems(results, statusFilter);
const failedResults = results.filter((result) => "error" in result);
if (isPending) return <WidgetMobileLoading />;
if (error) throw error;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
const up = systems.filter((system) => system.status === "up").length;
return (
<WidgetMobileSummary
value={systems.length}
label={label}
description={t("board.mobile.systemStatus", {
up,
attention: systems.length - up,
})}
isStale={failedResults.length > 0}
/>
);
};
+2
View File
@@ -10,6 +10,8 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
mobile: { supportsDetailView: true },
queryKey: [["app"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
+41 -26
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { Center, Stack, Text, useMantineTheme } from "@mantine/core";
import { Box, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
@@ -13,6 +13,7 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
@@ -40,10 +41,12 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !data) throw error;
return (
<CalendarBase
isEditMode={isEditMode}
@@ -52,6 +55,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth}
options={options}
displayMode={displayMode}
isStale={Boolean(error) || (data?.length ?? 0) < integrationIds.length}
/>
);
};
@@ -63,9 +67,18 @@ interface CalendarBaseProps {
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
isStale?: boolean;
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const CalendarBase = ({
isEditMode,
events,
month,
setMonth,
options,
displayMode,
isStale = false,
}: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -83,30 +96,32 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
.filter((event) => event.startDate.getTime() >= Date.now())
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime())[0];
const link = nextEvent?.links[0];
const summary = (
<WidgetMobileSummary
value={nextEvent?.title ?? normalizedEvents.length}
label={
nextEvent
? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate)
: t("name")
}
isStale={isStale}
/>
);
if (!link) return summary;
return (
<Center h="100%" p="md">
<Stack align="center" gap={4} maw="100%">
<Text
component={link ? "a" : "span"}
href={link?.href}
target={link ? "_blank" : undefined}
rel={link ? "noopener noreferrer" : undefined}
fw={700}
size="lg"
lineClamp={1}
maw="100%"
style={{ color: "inherit", textDecoration: "none" }}
>
{nextEvent?.title ?? normalizedEvents.length}
</Text>
<Text c="dimmed" size="sm" lineClamp={1}>
{nextEvent
? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate)
: t("name")}
</Text>
</Stack>
</Center>
<Box
component="a"
href={link.href}
target="_blank"
rel="noopener noreferrer"
h="100%"
display="block"
style={{ color: "inherit", textDecoration: "none" }}
>
{summary}
</Box>
);
}
+1
View File
@@ -15,6 +15,7 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar"
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+39 -11
View File
@@ -1,29 +1,57 @@
import { Center, Group, Stack, Text } from "@mantine/core";
"use client";
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface WidgetMobileSummaryProps {
value: number | string;
label: string;
description?: string;
isStale?: boolean;
}
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) {
const t = useI18n();
return (
<Center h="100%" p="sm">
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
<Text fz="xl" fw={700} lh={1}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{label}
</Text>
</Group>
<Text fz="xl" fw={700} lh={1.15} ta="center" w="100%" lineClamp={2} style={{ overflowWrap: "anywhere" }}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1} ta="center" w="100%">
{label}
</Text>
{description !== undefined && (
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1} ta="center" w="100%">
{description}
</Text>
)}
{isStale && (
<Group component="output" gap={4} justify="center" wrap="nowrap" w="100%">
<IconAlertTriangle
size={14}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
aria-hidden
/>
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1}>
{t("board.mobile.dataWarning")}
</Text>
</Group>
)}
</Stack>
</Center>
);
}
export function WidgetMobileLoading() {
const t = useI18n();
return (
<Center component="output" h="100%" aria-label={t("common.action.loading")}>
<Loader size="sm" />
</Center>
);
}
@@ -13,9 +13,10 @@ interface ApplicationsSectionProps {
applications: CoolifyApplicationWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
@@ -34,7 +35,14 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati
{applications.length > 0 ? (
<Stack gap={4}>
{applications.map((app) => (
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
<ResourceRow
key={app.uuid}
item={app}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="application"
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
) : (
+26 -4
View File
@@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -21,16 +26,24 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
isMobileDetail={displayMode === "mobileDetail"}
/>
);
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
isMobileDetail: boolean;
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, isMobileDetail }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
@@ -38,7 +51,15 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isMobileDetail={isMobileDetail}
/>
);
}
return (
@@ -51,6 +72,7 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
+2
View File
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
mobile: { supportsDetailView: true },
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
+15 -3
View File
@@ -18,9 +18,10 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isMobileDetail: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDetail }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -99,13 +100,24 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
<ApplicationsSection
applications={instance.instanceInfo.applications}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
</Accordion>
+38 -5
View File
@@ -3,6 +3,8 @@
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -21,11 +23,18 @@ interface ResourceRowProps {
baseUrl: string;
isTiny: boolean;
resourceType: "application" | "service";
isMobileDetail: boolean;
}
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetail }: ResourceRowProps) {
const tCommon = useScopedI18n("common");
const tApp = useScopedI18n("widget.app");
const status = parseStatus(item.status ?? "");
const statusColor = getStatusColor(status);
const fqdn = cleanFqdn(item.fqdn);
const actionIconSize = isMobileDetail ? 44 : "xs";
const resourceLabel = resourceType === "application" ? tCommon("applications") : tCommon("services");
const openInNewTabLabel = tApp("option.openInNewTab.label");
const resourceUrl =
item.projectUuid && item.environmentUuid
@@ -49,18 +58,42 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
{cleanFqdn(item.fqdn) && (
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
{fqdn && (
<ActionIcon
component="a"
href={fqdn}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${openInNewTabLabel}: ${fqdn}`}
>
<IconLink size={12} />
</ActionIcon>
)}
{resourceUrl && (
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<ActionIcon
component="a"
href={resourceUrl}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${openInNewTabLabel}: ${resourceLabel} ${item.name}`}
>
<IconExternalLink size={12} />
</ActionIcon>
)}
{logsUrl && (
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<ActionIcon
component="a"
href={logsUrl}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${tCommon("action.checkLogs")}: ${item.name}`}
>
<IconFileText size={12} />
</ActionIcon>
)}
@@ -15,6 +15,7 @@ interface ServersSectionProps {
isTiny: boolean;
showIp: boolean;
onToggleIp: () => void;
isMobileDetail: boolean;
}
export function ServersSection({
@@ -24,6 +25,7 @@ export function ServersSection({
isTiny,
showIp,
onToggleIp,
isMobileDetail,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
@@ -40,9 +42,11 @@ export function ServersSection({
</Badge>
</Group>
<ActionIcon
size="xs"
size={isMobileDetail ? 44 : "xs"}
variant="subtle"
c="dimmed"
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
aria-pressed={showIp}
onClick={(event) => {
event.stopPropagation();
onToggleIp();
@@ -65,6 +69,7 @@ export function ServersSection({
baseUrl={baseUrl}
isTiny={isTiny}
showIp={showIp}
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
@@ -84,10 +89,12 @@ interface ServerRowProps {
baseUrl: string;
isTiny: boolean;
showIp: boolean;
isMobileDetail: boolean;
}
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
const tApp = useScopedI18n("widget.app");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
@@ -110,7 +117,15 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<ActionIcon
component="a"
href={serverUrl}
target="_blank"
size={isMobileDetail ? 44 : "xs"}
variant="subtle"
c="dimmed"
aria-label={`${tApp("option.openInNewTab.label")}: ${server.name}`}
>
<IconExternalLink size={12} />
</ActionIcon>
<Text fz="10px" c="dimmed">
@@ -13,9 +13,10 @@ interface ServicesSectionProps {
services: CoolifyServiceWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
@@ -34,7 +35,14 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr
{services.length > 0 ? (
<Stack gap={4}>
{services.map((service) => (
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
<ResourceRow
key={service.uuid}
item={service}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="service"
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
) : (
@@ -18,9 +18,16 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isMobileDetail: boolean;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({
instance,
options,
isTiny,
widgetKey,
isMobileDetail,
}: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -65,13 +72,24 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
<ApplicationsSection
applications={instance.instanceInfo.applications}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
</Accordion>
@@ -309,14 +309,21 @@ function openJsonInBrowser(json: unknown) {
}
function RawDisplay({ data }: { data: Record<string, unknown> }) {
const tApp = useScopedI18n("widget.app");
const maxHeight = (data.maxHeight as number) ?? 300;
const jsonString = JSON.stringify(data.data, null, 2);
const openJsonLabel = `${tApp("option.openInNewTab.label")}: JSON`;
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<Tooltip label={openJsonLabel}>
<ActionIcon
variant="subtle"
size={44}
aria-label={openJsonLabel}
onClick={() => openJsonInBrowser(data.data)}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
+2
View File
@@ -6,6 +6,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
mobile: { supportsDetailView: true },
queryKey: [["widget", "customApi"]],
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
@@ -2,15 +2,19 @@
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const tCommon = useScopedI18n("common");
const { dir } = useDirection();
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
@@ -26,6 +30,8 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
const PreviousIcon = dir === "rtl" ? IconChevronRight : IconChevronLeft;
const NextIcon = dir === "rtl" ? IconChevronLeft : IconChevronRight;
return (
<Stack gap="xs">
@@ -34,22 +40,24 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
size={44}
disabled={clampedPage === 0}
aria-label={tCommon("action.previous")}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
<PreviousIcon size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
size={44}
disabled={clampedPage >= totalPages - 1}
aria-label={tCommon("action.next")}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
<NextIcon size={14} />
</ActionIcon>
</Group>
)}
+5 -2
View File
@@ -15,16 +15,18 @@ import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptions
export type WidgetDisplayMode = "default" | "mobileSummary" | "mobileDetail";
export interface WidgetMobilePresentation {
width: 1 | 2;
height: 1 | 2 | 3;
width?: 1 | 2;
height?: 1 | 2 | 3;
supportsCompactSummary?: boolean;
supportsDetailView?: boolean;
eager?: boolean;
unmountWhenOffscreen?: boolean;
}
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
mobileVisible?: boolean;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
@@ -111,6 +113,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
boardId: string | undefined; // undefined when in preview mode
displayMode?: WidgetDisplayMode;
isEditMode: boolean;
isReadOnly?: boolean;
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
@@ -42,12 +42,21 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
setMinutes(0);
}}
title={t("widget.dnsHoleControls.controls.setTimer")}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} −`}
onClick={() => hoursHandlers.current?.decrement()}
>
–
</ActionIcon>
<NumberInput
@@ -58,15 +67,26 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={999}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.hours")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} +`}
onClick={() => hoursHandlers.current?.increment()}
>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} −`}
onClick={() => minutesHandlers.current?.decrement()}
>
–
</ActionIcon>
<NumberInput
@@ -77,9 +97,15 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={59}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.minutes")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} +`}
onClick={() => minutesHandlers.current?.increment()}
>
+
</ActionIcon>
</Group>
@@ -91,7 +117,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
h="2rem"
mih={44}
w="12rem"
onClick={handleSetTimer}
>
@@ -301,6 +301,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
size="sm"
color="green"
variant="light"
aria-label={`${t("widget.dnsHoleControls.controls.enable")}: ${data.integration.name}`}
>
<IconPlayerPlay size={12} />
</ActionIcon>
@@ -311,6 +312,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
size="sm"
color="red"
variant="light"
aria-label={`${t("widget.dnsHoleControls.controls.disable")}: ${data.integration.name}`}
>
<IconPlayerStop size={12} />
</ActionIcon>
@@ -323,6 +325,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
size="sm"
color="yellow"
variant="light"
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
>
<IconClockPause size={12} />
</ActionIcon>
@@ -381,6 +384,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
bd={0}
ms={"auto"}
variant="subtle"
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
@@ -9,6 +9,7 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
+45 -9
View File
@@ -16,6 +16,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
@@ -35,6 +36,7 @@ const actionIconIconStyle: IconProps["style"] = {
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
actionIconSize: "xs" | 44,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
id: "name",
@@ -130,6 +132,7 @@ const createColumns = (
const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation({ onSettled });
const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation({ onSettled });
const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation({ onSettled });
const toggleAction = row.original.state === "running" ? "stop" : "start";
const handleActionAsync = async (action: "start" | "stop" | "restart") => {
const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer;
@@ -155,12 +158,13 @@ const createColumns = (
return (
<Group wrap="nowrap" gap="xs">
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
<Tooltip label={t(`action.${toggleAction}.label`)}>
<ActionIcon
variant="subtle"
size="xs"
size={actionIconSize}
radius="100%"
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
aria-label={`${t(`action.${toggleAction}.label`)}: ${row.original.name}`}
onClick={() => handleActionAsync(toggleAction)}
>
{row.original.state === "running" ? (
<IconPlayerStop style={actionIconIconStyle} />
@@ -170,7 +174,13 @@ const createColumns = (
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.restart.label")}>
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
<ActionIcon
variant="subtle"
size={actionIconSize}
radius="100%"
aria-label={`${t("action.restart.label")}: ${row.original.name}`}
onClick={() => handleActionAsync("restart")}
>
<IconRotateClockwise style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
@@ -180,12 +190,38 @@ const createColumns = (
},
];
export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) {
export default function DockerWidget({ displayMode, ...props }: WidgetComponentProps<"dockerContainers">) {
if (displayMode === "mobileSummary") return <DockerMobileSummary />;
return <DockerTable {...props} displayMode={displayMode} />;
}
const DockerMobileSummary = () => {
const t = useScopedI18n("docker");
const { data, error, isPending } = clientApi.docker.getContainers.useQuery();
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
const containers = data?.containers ?? [];
const runningContainers = containers.filter((container) => container.state === "running").length;
return (
<WidgetMobileSummary
value={runningContainers}
label={t("field.state.option.running")}
description={t("table.footer", { count: containers.length.toString() })}
isStale={Boolean(error)}
/>
);
};
const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponentProps<"dockerContainers">) => {
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const actionIconSize = displayMode === "mobileDetail" ? 44 : "xs";
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
const containers = data?.containers ?? [];
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -202,7 +238,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
);
}, [containers]);
const columns = useMemo(() => createColumns(t), [t]);
const columns = useMemo(() => createColumns(t, actionIconSize), [t, actionIconSize]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
@@ -300,7 +336,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
size="sm"
size={displayMode === "mobileDetail" ? 44 : "sm"}
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
@@ -315,4 +351,4 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
)}
</Stack>
);
}
};
+7
View File
@@ -25,6 +25,13 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["docker", "getContainers"]],
createOptions() {
return optionsBuilder.from((factory) => ({
+136 -50
View File
@@ -41,6 +41,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
@@ -48,6 +49,7 @@ import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(relativeTime);
@@ -78,13 +80,85 @@ const columnsRatios: Record<keyof ExtendedDownloadClientItem, number> = {
upSpeed: 3,
};
export default function DownloadClientsWidget({
type DownloadsWidgetOptions = WidgetComponentProps<"downloads">["options"];
type DownloadItem = RouterOutputs["widget"]["downloads"]["getJobsAndStatuses"][number]["data"]["items"][number];
type DownloadVisibilityOptions = Pick<
DownloadsWidgetOptions,
| "activeTorrentThreshold"
| "categoryFilter"
| "filterIsWhitelist"
| "showCompletedHttp"
| "showCompletedTorrent"
| "showCompletedUsenet"
>;
const matchesConfiguredFilters = (item: DownloadItem, options: DownloadVisibilityOptions) => {
const matchesCategoryFilter =
options.filterIsWhitelist ===
options.categoryFilter.some((filter) =>
(Array.isArray(item.category) ? item.category : [item.category]).includes(filter),
);
const isVisibleCompletedItem =
(item.type === "torrent" &&
((item.progress === 1 &&
options.showCompletedTorrent &&
(item.upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
item.progress !== 1)) ||
(item.type === "usenet" && ((item.progress === 1 && options.showCompletedUsenet) || item.progress !== 1)) ||
(item.type === "miscellaneous" && ((item.progress === 1 && options.showCompletedHttp) || item.progress !== 1));
return matchesCategoryFilter && isVisibleCompletedItem;
};
export default function DownloadClientsWidget(props: WidgetComponentProps<"downloads">) {
if (props.displayMode === "mobileSummary") {
return <DownloadClientsMobileSummary integrationIds={props.integrationIds} options={props.options} />;
}
return <DownloadClientsTable {...props} />;
}
const DownloadClientsMobileSummary = ({
integrationIds,
options,
}: Pick<WidgetComponentProps<"downloads">, "integrationIds" | "options">) => {
const t = useScopedI18n("widget.downloads");
const { data, error, isPending } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
const currentItems = data ?? [];
const selectedClients = currentItems.filter(({ integration }) => integrationIds.includes(integration.id));
const visibleItemCount = selectedClients.reduce(
(total, { data: clientData }) =>
total + clientData.items.filter((item) => matchesConfiguredFilters(item, options)).length,
0,
);
const totalDownloadSpeed = selectedClients.reduce(
(total, { data: clientData }) => total + (clientData.status.rates.down ?? 0),
0,
);
return (
<WidgetMobileSummary
value={visibleItemCount}
label={t("name")}
description={`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`}
isStale={Boolean(error) || currentItems.length < integrationIds.length}
/>
);
};
const DownloadClientsTable = ({
isEditMode,
integrationIds,
options,
setOptions,
displayMode,
}: WidgetComponentProps<"downloads">) {
}: WidgetComponentProps<"downloads">) => {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [],
);
@@ -119,6 +193,25 @@ export default function DownloadClientsWidget({
const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads);
const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads);
const visibilityOptions = useMemo<DownloadVisibilityOptions>(
() => ({
activeTorrentThreshold: options.activeTorrentThreshold,
categoryFilter: options.categoryFilter,
filterIsWhitelist: options.filterIsWhitelist,
showCompletedHttp: options.showCompletedHttp,
showCompletedTorrent: options.showCompletedTorrent,
showCompletedUsenet: options.showCompletedUsenet,
}),
[
options.activeTorrentThreshold,
options.categoryFilter,
options.filterIsWhitelist,
options.showCompletedHttp,
options.showCompletedTorrent,
options.showCompletedUsenet,
],
);
//Flatten Data array for which each element has its integration, data (base + calculated) and actions
const data = useMemo<ExtendedDownloadClientItem[]>(
() =>
@@ -129,24 +222,7 @@ export default function DownloadClientsWidget({
.flatMap((pair) =>
//Apply user white/black list
pair.data.items
.filter(
({ category }) =>
options.filterIsWhitelist ===
options.categoryFilter.some((filter) =>
(Array.isArray(category) ? category : [category]).includes(filter),
),
)
//Filter completed items following widget option
.filter(
({ type, progress, upSpeed }) =>
(type === "torrent" &&
((progress === 1 &&
options.showCompletedTorrent &&
(upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
progress !== 1)) ||
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) ||
(type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)),
)
.filter((item) => matchesConfiguredFilters(item, visibilityOptions))
//Filter following user quick setting
.filter(
({ state }) =>
@@ -184,13 +260,8 @@ export default function DownloadClientsWidget({
mutateDeleteItem,
mutatePauseItem,
mutateResumeItem,
options.activeTorrentThreshold,
options.categoryFilter,
options.filterIsWhitelist,
options.showCompletedTorrent,
options.showCompletedUsenet,
options.showCompletedHttp,
quickFilters,
visibilityOptions,
],
);
@@ -317,12 +388,24 @@ export default function DownloadClientsWidget({
return actions ? (
<Group wrap="nowrap" gap="xs">
<Tooltip label={t(`actions.item.${pausedAction}`)}>
<ActionIcon size="xs" variant="light" radius="100%" onClick={actions[pausedAction]}>
<ActionIcon
size="xs"
variant="light"
radius="100%"
aria-label={`${t(`actions.item.${pausedAction}`)}: ${row.original.name}`}
onClick={actions[pausedAction]}
>
{pausedAction === "resume" ? <IconPlayerPlay /> : <IconPlayerPause />}
</ActionIcon>
</Tooltip>
<Tooltip label={t("actions.item.delete.title")}>
<ActionIcon size="xs" color="red" radius="100%" onClick={open}>
<ActionIcon
size="xs"
color="red"
radius="100%"
aria-label={`${t("actions.item.delete.title")}: ${row.original.name}`}
onClick={open}
>
<IconTrash />
</ActionIcon>
</Tooltip>
@@ -354,7 +437,12 @@ export default function DownloadClientsWidget({
</Modal>
</Group>
) : (
<ActionIcon size="xs" radius="100%" disabled>
<ActionIcon
size="xs"
radius="100%"
aria-label={`${t("items.actions.columnTitle")}: ${row.original.name}`}
disabled
>
<IconX />
</ActionIcon>
);
@@ -606,23 +694,6 @@ export default function DownloadClientsWidget({
{ up: 0, down: 0 },
);
if (displayMode === "mobileSummary") {
const totalDownloadSpeed = clients.reduce((total, { status }) => total + (status?.rates.down ?? 0), 0);
return (
<Center h="100%" p="md">
<Stack align="center" gap={4} maw="100%">
<Text fw={700} size="xl">
{data.length}
</Text>
<Text c="dimmed" size="sm" lineClamp={1}>
{`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`}
</Text>
</Stack>
</Center>
);
}
if (options.columns.length === 0)
return (
<Center h="100%">
@@ -657,7 +728,7 @@ export default function DownloadClientsWidget({
<ItemInfoModal items={data} currentIndex={clickedIndex} opened={opened} onClose={close} />
</Stack>
);
}
};
interface ItemInfoModalProps {
items: ExtendedDownloadClientItem[];
@@ -770,11 +841,17 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads);
const [opened, { open, close }] = useDisclosure(false);
const t = useScopedI18n("widget.downloads");
const tCommon = useScopedI18n("common");
return (
<Group gap={5}>
<Popover withinPortal={false} offset={0}>
<Popover.Target>
<ActionIcon size="xs" radius="lg" variant="light">
<ActionIcon
size="xs"
radius="lg"
variant="light"
aria-label={`${tCommon("action.show")}: ${t("items.integration.columnTitle")} / ${t("items.state.columnTitle")}`}
>
<IconFilter />
</ActionIcon>
</Popover.Target>
@@ -819,6 +896,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
radius="lg"
disabled={integrationsStatuses.paused.length === 0}
variant="light"
aria-label={t("actions.clients.resume")}
onClick={() => mutateResumeQueue({ integrationIds: integrationsStatuses.paused })}
>
<IconPlayerPlay />
@@ -843,6 +921,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
radius="xl"
disabled={integrationsStatuses.active.length === 0}
variant="light"
aria-label={t("actions.clients.pause")}
onClick={() => mutatePauseQueue({ integrationIds: integrationsStatuses.active })}
>
<IconPlayerPause />
@@ -900,6 +979,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
radius={999}
variant="light"
size="lg"
aria-label={`${t(`actions.client.${client.status.paused ? "resume" : "pause"}`)}: ${client.integration.name}`}
onClick={() => {
(client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({
integrationIds: [client.integration.id],
@@ -910,7 +990,13 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
</ActionIcon>
</Tooltip>
) : (
<ActionIcon radius={999} variant="light" size="lg" disabled>
<ActionIcon
radius={999}
variant="light"
size="lg"
aria-label={`${t("items.actions.columnTitle")}: ${client.integration.name}`}
disabled
>
<IconX />
</ActionIcon>
)}
+2
View File
@@ -38,7 +38,9 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "downloads"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
+2
View File
@@ -7,6 +7,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
mobile: { supportsDetailView: true },
queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { SystemHealthMonitoring } from "./system-health";
@@ -18,11 +19,22 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const { data: integrations = [], error, isPending } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
if (props.displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (props.displayMode === "mobileSummary" && error && integrations.length === 0) throw error;
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
if (props.displayMode === "mobileSummary") {
return clusterIntegrationId ? (
<ClusterHealthMobileSummary integrationId={clusterIntegrationId} isStale={Boolean(error)} />
) : (
<SystemHealthMobileSummary integrationIds={props.integrationIds} isStale={Boolean(error)} />
);
}
if (!clusterIntegrationId) {
return <SystemHealthMonitoring {...props} />;
}
@@ -35,24 +47,79 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
}
return (
<ScrollArea h="100%">
<Tabs defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
</Tabs.Tab>
<Tabs.Tab value="cluster" fz="xs">
<b>{t("widget.healthMonitoring.tab.cluster")}</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
</Tabs.Panel>
</Tabs>
</ScrollArea>
const tabs = (
<Tabs defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
</Tabs.Tab>
<Tabs.Tab value="cluster" fz="xs">
<b>{t("widget.healthMonitoring.tab.cluster")}</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
</Tabs.Panel>
</Tabs>
);
if (props.displayMode === "mobileDetail") return tabs;
return <ScrollArea h="100%">{tabs}</ScrollArea>;
}
const SystemHealthMobileSummary = ({ integrationIds, isStale }: { integrationIds: string[]; isStale: boolean }) => {
const t = useI18n();
const { data, error, isPending } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
const healthInfo = data?.[0]?.healthInfo;
if (!healthInfo) return <WidgetMobileSummary value="—" label={t("widget.healthMonitoring.name")} />;
const totalMemory = healthInfo.memAvailableInBytes + healthInfo.memUsedInBytes;
const memoryPercent = totalMemory > 0 ? Math.round((healthInfo.memUsedInBytes / totalMemory) * 100) : 0;
return (
<WidgetMobileSummary
value={`${Math.round(healthInfo.cpuUtilization)}%`}
label={t("widget.healthMonitoring.cluster.summary.cpu")}
description={`${t("widget.healthMonitoring.cluster.summary.memory")} ${memoryPercent}%`}
isStale={isStale || Boolean(error) || (data?.length ?? 0) < integrationIds.length}
/>
);
};
const ClusterHealthMobileSummary = ({ integrationId, isStale }: { integrationId: string; isStale: boolean }) => {
const t = useI18n();
const { data, error, isPending } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
if (!data) return <WidgetMobileSummary value="—" label={t("widget.healthMonitoring.name")} />;
const runningNodes = data.nodes.filter((node) => node.isRunning);
const totalCpu = runningNodes.reduce((sum, node) => sum + node.cpu.cores, 0);
const usedCpu = runningNodes.reduce((sum, node) => sum + node.cpu.utilization * node.cpu.cores, 0);
const totalMemory = runningNodes.reduce((sum, node) => sum + node.memory.total, 0);
const usedMemory = runningNodes.reduce((sum, node) => sum + node.memory.used, 0);
const cpuPercent = totalCpu > 0 ? Math.round((usedCpu / totalCpu) * 100) : 0;
const memoryPercent = totalMemory > 0 ? Math.round((usedMemory / totalMemory) * 100) : 0;
return (
<WidgetMobileSummary
value={`${cpuPercent}%`}
label={t("widget.healthMonitoring.cluster.summary.cpu")}
description={`${t("widget.healthMonitoring.cluster.summary.memory")} ${memoryPercent}%`}
isStale={isStale || Boolean(error)}
/>
);
};
@@ -8,6 +8,14 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
@@ -83,7 +83,12 @@ export const SystemHealthMonitoring = ({
p="sm"
pos="relative"
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Box
className="health-monitoring-information-card-section"
pos="absolute"
top={8}
style={{ insetInlineStart: 8 }}
>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
@@ -99,10 +104,12 @@ export const SystemHealthMonitoring = ({
className="health-monitoring-information-icon-avatar"
variant={"light"}
color="var(--mantine-color-text)"
size="sm"
size={44}
radius={board.itemRadius}
aria-label={t("widget.healthMonitoring.popover.information")}
onClick={open}
>
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
<IconInfoCircle className="health-monitoring-information-icon" size={24} />
</ActionIcon>
</Indicator>
<Modal
@@ -111,6 +118,10 @@ export const SystemHealthMonitoring = ({
size="auto"
title={t("widget.healthMonitoring.popover.information")}
centered
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Stack gap="10px" className="health-monitoring-modal-stack">
<Divider />
+16 -2
View File
@@ -6,10 +6,11 @@ import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
@@ -19,6 +20,11 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
if (!isSupportedProtocol(embedUrl)) {
return <UnsupportedProtocol />;
}
const frameLabel = getFrameLabel(embedUrl);
if (displayMode === "mobileSummary") {
return <WidgetMobileSummary value={frameLabel} label={t("widget.iframe.name")} />;
}
return (
<Box h="100%" w="100%">
@@ -26,7 +32,7 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title="widget iframe"
title={`${t("widget.iframe.name")}: ${frameLabel}`}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -39,6 +45,14 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
const supportedProtocols = ["http", "https"];
const getFrameLabel = (url: string) => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};
const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
+6
View File
@@ -5,6 +5,12 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
icon: IconBrowser,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
},
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
@@ -7,6 +7,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
mobile: { supportsDetailView: true },
queryKey: [["widget", "indexerManager"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -23,7 +23,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileSummary } from "../common/mobile-summary";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -37,11 +37,13 @@ export default function MediaMissingWidget({
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
const { data, error, isPending } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !data) throw error;
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
if (!options.showMissing && !options.showQueued)
@@ -64,7 +66,14 @@ export default function MediaMissingWidget({
: { value: queuedCount, label: t("tab.queued") };
const description = options.showMissing && options.showQueued ? `${queuedCount} ${t("tab.queued")}` : undefined;
return <WidgetMobileSummary value={primary.value} label={primary.label} description={description} />;
return (
<WidgetMobileSummary
value={primary.value}
label={primary.label}
description={description}
isStale={Boolean(error)}
/>
);
}
const isThin = width > 0 && width < 160;
@@ -15,6 +15,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaOrganizer"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
@@ -15,23 +15,36 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { WidgetMobileSummary } from "../common/mobile-summary";
export default function MediaReleasesWidget({
options,
integrationIds,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
const t = useI18n("widget.mediaReleases");
if (displayMode === "mobileSummary") {
return <WidgetMobileSummary value={releases.length} label={t("name")} description={releases[0]?.title} />;
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
const releases = data ?? [];
return (
<WidgetMobileSummary
value={releases.length}
label={t("name")}
description={releases[0]?.title}
isStale={Boolean(error)}
/>
);
}
const releases = data ?? [];
return (
<Stack p="xs" gap="sm">
{releases.map((item, index) => (
@@ -26,7 +26,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { WidgetMobileSummary } from "../../common/mobile-summary";
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
@@ -39,7 +39,11 @@ export default function MediaServerWidget({
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList");
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
const {
data: mediaRequests,
error,
isPending,
} = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
@@ -48,11 +52,20 @@ export default function MediaServerWidget({
recentDays: options.recentDays,
});
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !mediaRequests) throw error;
if (!mediaRequests) return <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
if (displayMode === "mobileSummary") {
return <WidgetMobileSummary value={mediaRequests.length} label={t("name")} description={mediaRequests[0]?.name} />;
return (
<WidgetMobileSummary
value={mediaRequests.length}
label={t("name")}
description={mediaRequests[0]?.name}
isStale={Boolean(error)}
/>
);
}
return (
@@ -231,7 +244,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
size="xs"
size={44}
aria-label={t("pending.approve")}
onClick={() => {
handleDecision("approve");
}}
@@ -244,7 +258,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
className="mediaRequests-list-item-pending-button-decline"
variant="light"
color="red"
size="xs"
size={44}
aria-label={t("pending.decline")}
onClick={() => {
handleDecision("decline");
}}
@@ -17,6 +17,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaRequests"]],
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -38,6 +39,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
contextActions: ({ integrationIds }) => [
{
key: "search",
mobileVisible: true,
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
@@ -7,12 +7,15 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
mobile: { supportsDetailView: true },
queryKey: [["widget", "mediaRequests"]],
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
mobileVisible: true,
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
+72 -17
View File
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { Fragment, useCallback, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import {
IconDeviceTv,
@@ -24,9 +24,47 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
export default function MediaServerWidget({ displayMode, ...props }: WidgetComponentProps<"mediaServer">) {
if (displayMode === "mobileSummary") return <MediaServerMobileSummary {...props} />;
return <MediaServerTable {...props} />;
}
const MediaServerMobileSummary = ({
options,
integrationIds,
}: Pick<WidgetComponentProps<"mediaServer">, "options" | "integrationIds">) => {
const t = useScopedI18n("widget.mediaServer");
const { data, error, isPending } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
});
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
const currentStreams = data ?? [];
const hasPartialFailure = currentStreams.length < integrationIds.length;
const playingSessions = currentStreams
.flatMap((stream) => stream.sessions)
.filter((session) => session.currentlyPlaying !== null);
return (
<WidgetMobileSummary
value={playingSessions.length}
label={t("items.currentlyPlaying")}
description={playingSessions[0]?.currentlyPlaying?.name}
isStale={Boolean(error) || hasPartialFailure}
/>
);
};
const MediaServerTable = ({
options,
integrationIds,
}: Pick<WidgetComponentProps<"mediaServer">, "options" | "integrationIds">) => {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
@@ -77,7 +115,9 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
positionMs !== null && durationMs !== null
? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
: null;
return (
<Stack gap={4} style={{ minWidth: 0 }}>
@@ -124,8 +164,8 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
return (
@@ -165,6 +205,17 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
);
const { openModal } = useModalAction(ItemInfoModal);
const openSession = useCallback(
(session: StreamSession) => {
openModal(
{ item: session },
{
title: session.sessionName,
},
);
},
[openModal],
);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
@@ -210,17 +261,21 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
height: "100%",
},
},
mantineTableBodyCellProps: ({ row }) => ({
onClick: () => {
openModal(
{
item: row.original,
},
{
title: row.original.sessionName,
},
);
mantineTableBodyRowProps: ({ row }) => ({
tabIndex: 0,
"aria-haspopup": "dialog",
"aria-label": row.original.currentlyPlaying
? `${row.original.sessionName}: ${row.original.currentlyPlaying.name}`
: row.original.sessionName,
onClick: () => openSession(row.original),
onKeyDown: (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openSession(row.original);
},
style: { cursor: "pointer" },
}),
mantineTableBodyCellProps: () => ({
py: 4,
style: {
overflowX: "hidden",
@@ -230,7 +285,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
});
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
const session = flatSessions.find((session) => session.integrationKind === kind);
const session = flatSessions.find((candidate) => candidate.integrationKind === kind);
return {
integrationKind: kind,
integrationIcon: session?.integrationIcon,
@@ -262,7 +317,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
</Group>
</Stack>
);
}
};
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
@@ -7,6 +7,14 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "mediaServer"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -10,7 +10,7 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileSummary } from "../common/mobile-summary";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -40,11 +40,13 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: queuePage,
};
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const { data: transcodingData, error, isPending } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !transcodingData) throw error;
if (!transcodingData) return <WidgetEmptyState />;
if (displayMode === "mobileSummary") {
@@ -64,6 +66,7 @@ export default function MediaTranscodingWidget({
value={summary.value}
label={summary.label}
description={t(failedHealthChecks > 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")}
isStale={Boolean(error)}
/>
);
}
@@ -17,6 +17,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaTranscoding"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -2,6 +2,9 @@
import dynamic from "next/dynamic";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), {
@@ -9,5 +12,23 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note
});
export default function NotebookWidget(props: WidgetComponentProps<"notebook">) {
const t = useScopedI18n("widget.notebook");
if (props.displayMode === "mobileSummary") {
const excerpt = props.options.content
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&(?:nbsp|#160);/gi, " ")
.replace(/&(?:amp|#38);/gi, "&")
.replace(/&(?:lt|#60);/gi, "<")
.replace(/&(?:gt|#62);/gi, ">")
.replace(/&(?:quot|#34);/gi, '"')
.replace(/\s+/g, " ")
.trim();
return <WidgetMobileSummary value={excerpt || "—"} label={t("name")} />;
}
return <Notebook {...props} />;
}
+6
View File
@@ -6,6 +6,12 @@ import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
icon: IconNotes,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
+9 -2
View File
@@ -89,7 +89,14 @@ const controlIconProps = {
stroke: 1.5,
};
export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
export function Notebook({
options,
setOptions,
isEditMode,
isReadOnly = false,
boardId,
itemId,
}: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(content);
@@ -97,7 +104,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
const { data: session } = useSession();
const { hasChangeAccess } = constructBoardPermissions(board, session);
const canChange = !isEditMode && hasChangeAccess;
const canChange = !isEditMode && !isReadOnly && hasChangeAccess;
const [isEditing, setIsEditing] = useState(false);
const { primaryColor } = useMantineTheme();
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Avatar, Box, Card, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { Avatar, Box, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -9,6 +9,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
export default function NotificationsWidget({
@@ -16,7 +17,11 @@ export default function NotificationsWidget({
integrationIds,
displayMode,
}: WidgetComponentProps<"notifications">) {
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
const {
data: notificationIntegrations,
error,
isPending,
} = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
@@ -27,38 +32,39 @@ export default function NotificationsWidget({
const sortedNotifications = useMemo(
() =>
notificationIntegrations
(notificationIntegrations ?? [])
.flatMap((integration) => integration.data)
.sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()),
[notificationIntegrations],
);
if (displayMode === "mobileSummary") {
if (isPending) return <WidgetMobileLoading />;
if (error && !notificationIntegrations) throw error;
const notifications = notificationIntegrations ?? [];
const latestNotification = sortedNotifications[0];
const isStale = Boolean(error) || notifications.length < integrationIds.length;
const summary = latestNotification ? (
<NotificationMobileSummary notification={latestNotification} isStale={isStale} />
) : (
<WidgetMobileSummary value={0} label={t("name")} description={t("noItems")} isStale={isStale} />
);
if (!latestNotification?.href) return summary;
return (
<Center h="100%" p="md">
{latestNotification ? (
latestNotification.href ? (
<Box
component="a"
href={latestNotification.href}
target="_blank"
rel="noopener noreferrer"
maw="100%"
style={{ color: "inherit", textDecoration: "none" }}
>
<NotificationSummary notification={latestNotification} />
</Box>
) : (
<NotificationSummary notification={latestNotification} />
)
) : (
<Text size="sm" c="dimmed">
{t("noItems")}
</Text>
)}
</Center>
<Box
component="a"
href={latestNotification.href}
target="_blank"
rel="noopener noreferrer"
h="100%"
display="block"
style={{ color: "inherit", textDecoration: "none" }}
>
{summary}
</Box>
);
}
@@ -113,22 +119,21 @@ export default function NotificationsWidget({
);
}
const NotificationSummary = ({
const NotificationMobileSummary = ({
notification,
isStale,
}: {
notification: {
title: string | null;
body: string;
time: Date;
};
}) => (
<Stack align="center" gap={4} maw="100%">
<Text fw={700} size="lg" lineClamp={1} maw="100%">
{notification.title ?? notification.body}
</Text>
<InfoDisplay date={notification.time} />
</Stack>
);
isStale: boolean;
}) => {
const timeAgo = useTimeAgo(notification.time, 30000);
return <WidgetMobileSummary value={notification.title ?? notification.body} label={timeAgo} isStale={isStale} />;
};
const InfoDisplay = ({ date }: { date: Date }) => {
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
@@ -13,6 +13,7 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "notifications"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
+38 -12
View File
@@ -8,6 +8,8 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
mobile: { supportsDetailView: true },
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
createOptions() {
@@ -188,18 +190,42 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon"
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
+2
View File
@@ -14,6 +14,8 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
mobile: { supportsDetailView: true },
queryKey: [["widget", "releases"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+45 -39
View File
@@ -17,7 +17,7 @@ const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]
return feedEntries;
};
export default function RssFeed({ options, width }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) {
const feedEntries = useLiveFeedEntries({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -29,49 +29,55 @@ export default function RssFeed({ options, width }: WidgetComponentProps<"rssFee
const isNarrow = width < 128 * 3;
return (
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
<Stack w={"100%"} gap="sm">
{feedEntries.map((feedEntry) => (
<Card
key={feedEntry.id}
component={"a"}
href={feedEntry.link}
radius={board.itemRadius}
target="_blank"
w="100%"
p="sm"
>
{feedEntry.enclosure !== undefined && (
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
const feed = (
<Stack w="100%" gap="sm" p="sm">
{feedEntries.map((feedEntry) => (
<Card
key={feedEntry.id}
component={"a"}
href={feedEntry.link}
radius={board.itemRadius}
target="_blank"
w="100%"
p="sm"
>
{feedEntry.enclosure !== undefined && (
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
)}
<Group wrap="nowrap">
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
<Image src={feedEntry.enclosure} alt={feedEntry.title} w={140} h={140} radius="sm" />
)}
<Group wrap="nowrap">
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
<Image src={feedEntry.enclosure} alt={feedEntry.title} w={140} h={140} radius="sm" />
<Flex gap="sm" direction="column" w="100%">
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && (
<Text
className={feedEntry.description}
dir={languageDir}
c="dimmed"
size="sm"
lineClamp={options.textLinesClamp}
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
/>
)}
<Flex gap="sm" direction="column" w="100%">
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && (
<Text
className={feedEntry.description}
dir={languageDir}
c="dimmed"
size="sm"
lineClamp={options.textLinesClamp}
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
/>
)}
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Group>
</Card>
))}
</Stack>
);
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Group>
</Card>
))}
</Stack>
if (displayMode === "mobileDetail") return feed;
return (
<ScrollArea className="scroll-area-w100" w="100%">
{feed}
</ScrollArea>
);
}
+2
View File
@@ -12,6 +12,8 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
mobile: { supportsDetailView: true },
queryKey: [["widget", "rssFeed"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -13,6 +13,7 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
supportsCompactSummary: true,
eager: true,
},
queryKey: [["widget", "smartHome"]],
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more