Revert "feat: refine mobile dashboard experience"

This reverts commit 315520beca.
This commit is contained in:
ajnart committed 2026-07-29 20:07:17 +02:00
1 parent bbc560706a
commit 5c4cc81b05
114 files changed
+535 -2349

No files matched your search

+22 -57
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,29 +22,24 @@ 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)
@@ -54,9 +49,7 @@ 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)
@@ -64,7 +57,6 @@ 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:
@@ -73,12 +65,10 @@ 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.
@@ -102,55 +92,41 @@ 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.
@@ -159,16 +135,14 @@ 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 |
@@ -187,23 +161,18 @@ 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:
@@ -215,7 +184,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.
:::
@@ -225,51 +194,47 @@ 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,5 +1,4 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
@@ -15,7 +14,6 @@ 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";
@@ -29,7 +27,6 @@ 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" });
@@ -54,8 +51,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const resolvedParams = await params;
const queryClient = getQueryClient();
const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]);
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -69,19 +65,17 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const [integrations] = await Promise.all([
getIntegrationsWithPermissionsAsync(session),
...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 },
),
);
}),
),
...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 },
),
);
}),
),
]);
const userId = session?.user.id ?? "anonymous";
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo } 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,7 +47,6 @@ 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";
@@ -269,7 +268,6 @@ 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"),
@@ -277,16 +275,12 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
);
const jumpToSection = (anchorId: string) => {
pendingSectionAnchorId.current = anchorId;
disclosure.close();
};
const focusPendingSection = () => {
const anchorId = pendingSectionAnchorId.current;
pendingSectionAnchorId.current = null;
if (!anchorId) return;
focusMobileBoardSection({ anchorId, reduceMotion });
requestAnimationFrame(() => {
const heading = document.getElementById(anchorId);
heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
heading?.focus({ preventScroll: true });
});
};
return (
@@ -302,53 +296,8 @@ 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"
@@ -424,6 +373,28 @@ 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>
</>
@@ -1,17 +0,0 @@
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);
});
});
@@ -1,7 +0,0 @@
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,22 +8,12 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { BoardLogo } 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}>
@@ -36,7 +26,7 @@ export const BoardSwitcherLogo = () => {
>
<Group gap="xs" wrap="nowrap">
<BoardLogo size={28} />
<Text fw={600} truncate title={board.name}>
<Text fw={600} truncate>
{board.name}
</Text>
<IconChevronDown size={16} aria-hidden />
@@ -56,9 +46,7 @@ export const BoardSwitcherLogo = () => {
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
aria-current={isCurrent ? "page" : undefined}
>
<Text truncate title={availableBoard.name}>
{availableBoard.name}
</Text>
{availableBoard.name}
</Menu.Item>
);
})}
@@ -26,7 +26,6 @@ interface BoardItemContentProps {
item: SectionItem;
displayMode?: WidgetDisplayMode;
disableContextMenu?: boolean;
isReadOnly?: boolean;
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
}
@@ -40,7 +39,6 @@ export const BoardItemContent = ({
item,
displayMode = "default",
disableContextMenu = false,
isReadOnly = false,
widgetStateRef: externalWidgetStateRef,
}: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
@@ -72,7 +70,6 @@ export const BoardItemContent = ({
width={width}
height={height}
displayMode={displayMode}
isReadOnly={isReadOnly}
widgetStateRef={widgetStateRef}
/>
</Card>
@@ -91,8 +88,9 @@ export const BoardItemContent = ({
<Badge
pos="absolute"
// It's 4 because of the mantine-react-table that has z-index 3
style={{ zIndex: 4, insetInlineStart: 16 }}
style={{ zIndex: 4 }}
top={2}
left={16}
size="xs"
radius={board.itemRadius}
styles={{
@@ -116,11 +114,10 @@ interface InnerContentProps {
width: number;
height: number;
displayMode: WidgetDisplayMode;
isReadOnly: boolean;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => {
const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -145,7 +142,7 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
@@ -158,23 +155,21 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} />}
<BoardItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
isReadOnly={isReadOnly}
setOptions={(partialNewOptions) => {
if (isReadOnly) return;
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
});
}}
})
}
displayMode={displayMode}
{...dimensions}
/>
@@ -1,18 +0,0 @@
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,25 +8,14 @@ import classes from "./mobile-board.module.css";
interface DeferredMobileItemProps extends PropsWithChildren {
eager: boolean;
unmountWhenOffscreen: boolean;
}
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) => {
export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => {
const [hasMounted, setHasMounted] = useState(eager);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hasMountedOnceRef = useRef(eager);
useEffect(() => {
if (eager) {
hasMountedOnceRef.current = true;
setHasMounted(true);
return;
}
if (eager || hasMounted) return;
const element = wrapperRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
@@ -34,37 +23,17 @@ export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: De
return;
}
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]);
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setHasMounted(true);
observer.disconnect();
},
{ rootMargin: "200% 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [eager, hasMounted]);
return (
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
@@ -6,18 +6,6 @@
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));
@@ -30,6 +18,8 @@
position: relative;
min-width: 0;
min-height: 0;
content-visibility: auto;
contain-intrinsic-size: auto 12rem;
}
.deferredItem {
@@ -44,18 +34,9 @@
}
.sectionHeading {
width: fit-content;
max-width: 100%;
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
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);
padding-block: var(--mantine-spacing-xs);
outline-offset: 4px;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.empty {
@@ -67,21 +48,14 @@
position: absolute;
z-index: 11;
top: 4px;
inset-inline-end: 4px;
right: 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,7 +13,6 @@ 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";
@@ -71,16 +70,19 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
data-mobile-board-item={item.id}
data-mobile-display-mode={presentation.displayMode}
>
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
<DeferredMobileItem eager={presentation.eager}>
<BoardItemContent
item={item}
displayMode={presentation.displayMode}
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
<MobileWidgetActions
item={item}
supportsDetails={presentation.supportsDetails}
widgetStateRef={widgetStateRef}
/>
</DeferredMobileItem>
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
</Box>
);
};
@@ -88,7 +90,6 @@ 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]);
@@ -109,9 +110,6 @@ 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}>
@@ -122,7 +120,7 @@ export const MobileBoard = () => {
{t("board.mobile.empty.title")}
</Title>
<Text c="dimmed" size="sm">
{t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
{t("board.mobile.empty.description")}
</Text>
</Stack>
</Center>
@@ -132,12 +130,10 @@ export const MobileBoard = () => {
<Title
key={block.id}
id={block.anchorId}
order={block.headingLevel}
size={block.headingLevel === 2 ? "h4" : block.headingLevel === 3 ? "h5" : "h6"}
fw={block.headingLevel === 2 ? 700 : 600}
order={2}
size="h4"
className={classes.sectionHeading}
tabIndex={-1}
style={{ marginInlineStart: Math.max(0, block.headingLevel - 2) * 12 }}
>
{block.title}
</Title>
@@ -1,35 +0,0 @@
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);
});
});
@@ -1,47 +0,0 @@
"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,10 +147,7 @@ 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", () => {
@@ -183,31 +180,4 @@ 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,7 +19,6 @@ export type MobileBoardSectionHeading = {
sectionId: string;
title: string;
anchorId: string;
headingLevel: 2 | 3 | 4 | 5 | 6;
};
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
@@ -52,10 +51,7 @@ const getLayout = <TLayout extends PositionedLayout>(
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
const createSectionHeading = (
section: CategorySection | DynamicSectionItem,
headingDepth: number,
): MobileBoardSectionHeading | null => {
const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => {
const title = (section.kind === "category" ? section.name : section.options.title).trim();
if (title.length === 0) return null;
@@ -65,7 +61,6 @@ const createSectionHeading = (
sectionId: section.id,
title,
anchorId: getMobileSectionAnchorId(section.id),
headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"],
};
};
@@ -117,18 +112,16 @@ 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, heading ? headingDepth + 1 : headingDepth);
const nestedResult = createSectionElements(element);
sectionElements.push(...nestedResult.elements);
sectionItems.push(...nestedResult.items);
continue;
@@ -143,6 +136,7 @@ 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,10 +1,6 @@
import { describe, expect, test } from "vitest";
import {
isMobileContextActionVisible,
resolveMobileItemPresentation,
shouldRenderMobileWidgetActions,
} from "./mobile-presentation";
import { resolveMobileItemPresentation } from "./mobile-presentation";
describe("resolveMobileItemPresentation", () => {
test("renders apps as eager compact tiles", () => {
@@ -13,51 +9,19 @@ describe("resolveMobileItemPresentation", () => {
height: 1,
eager: true,
supportsDetails: false,
unmountWhenOffscreen: false,
});
});
test("defaults widgets to full width without inventing a detail view", () => {
test("defaults widgets to full width and clamps desktop height", () => {
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("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", () => {
test("prefers explicit presentation metadata", () => {
expect(
resolveMobileItemPresentation(
{ kind: "clock", height: 3 },
@@ -67,7 +31,6 @@ describe("resolveMobileItemPresentation", () => {
supportsCompactSummary: true,
supportsDetailView: false,
eager: true,
unmountWhenOffscreen: true,
},
),
).toEqual({
@@ -76,37 +39,6 @@ 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 { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets";
import type { WidgetMobilePresentation } from "@homarr/widgets";
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
@@ -8,20 +8,6 @@ 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 === true,
supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app",
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;
@@ -1,28 +0,0 @@
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);
});
});
@@ -1,13 +0,0 @@
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, useRef } from "react";
import { useMemo } 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,9 +18,8 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { useHasNestedDialog } from "./mobile-dialog-stack";
import { useItemActions } from "../items/item-actions";
import classes from "./mobile-board.module.css";
import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
interface MobileWidgetActionsProps {
item: SectionItem;
@@ -31,16 +30,18 @@ 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 queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined;
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 options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, item.options, settings],
@@ -50,32 +51,36 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
return definition.contextActions({
options,
setOptions: () => undefined,
setOptions: (partial: Record<string, unknown>) =>
updateItemOptions({
itemId: item.id,
newOptions: { ...options, ...partial },
}),
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, widgetStateRef]);
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
const visibleContextActions = contextActions.filter((action) => !action.hidden);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const hasActions = shouldRenderMobileWidgetActions({
supportsDetails,
supportsRefresh: queryKey !== undefined,
visibleContextActionCount: visibleContextActions.length,
});
const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
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", { title })}
aria-label={t("board.mobile.actions")}
onClick={actionsDisclosure.open}
>
<IconDotsVertical size={20} />
@@ -89,21 +94,6 @@ 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 && (
@@ -113,8 +103,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")}
@@ -140,74 +130,37 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
</Button>
);
})}
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
<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>
</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: {
flex: "1 1 auto",
minHeight: 0,
height: "calc(100dvh - 4rem)",
overflowY: "auto",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent
item={item}
displayMode="mobileDetail"
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu 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,15 +6,11 @@ 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
@@ -33,14 +29,9 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => {
const t = useI18n();
const isMobileBoard = useIsMobileBoard();
return (
<HeaderButton
onClick={openSpotlight}
className={isMobileBoard ? undefined : classes.mobileSearch}
aria-label={t("search.placeholder")}
>
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch} aria-label={t("search.placeholder")}>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
);
-243
View File
@@ -1,243 +0,0 @@
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);
});
+2 -4
View File
@@ -1592,9 +1592,6 @@ 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) {
@@ -1616,7 +1613,8 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
+3 -64
View File
@@ -516,64 +516,6 @@ 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");
@@ -1485,12 +1427,9 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
await db
.insert(users)
.values({
id: defaultCreatorId,
})
.onConflictDoNothing();
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
+25 -6
View File
@@ -1,13 +1,16 @@
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({
@@ -27,9 +30,8 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
@@ -42,8 +44,25 @@ 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 [];
});
}),
});
+8 -12
View File
@@ -24,18 +24,14 @@ 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,
};
},
{ throwOnAllFailure: true },
);
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,
};
});
}),
pause: protectedProcedure
.meta({
@@ -27,19 +27,15 @@ 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,
};
},
{ throwOnAllFailure: true },
);
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,
};
});
}),
listStorageVolumes: publicProcedure
.meta({
@@ -9,24 +9,20 @@ 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,
};
},
{ throwOnAllFailure: true },
);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
});
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
@@ -25,14 +25,10 @@ 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 };
},
{ throwOnAllFailure: true },
);
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 };
});
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
@@ -61,14 +57,10 @@ 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 };
},
{ throwOnAllFailure: true },
);
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 };
});
return {
stats: results.flatMap((result) => result.data.stats),
users: results
@@ -23,15 +23,11 @@ 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 };
},
{ throwOnAllFailure: true },
);
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 };
});
}),
});
@@ -13,23 +13,19 @@ 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,
};
},
{ throwOnAllFailure: true },
);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
}),
});
+11 -15
View File
@@ -8,21 +8,17 @@ 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,
};
},
{ throwOnAllFailure: true },
);
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
}),
});
@@ -1,43 +0,0 @@
// @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([]);
});
});
+3 -9
View File
@@ -1853,8 +1853,6 @@
"internalServerError": "Failed to control DNS Hole"
},
"controls": {
"enable": "Enable",
"disable": "Disable",
"enableAll": "Enable All",
"disableAll": "Disable All",
"setTimer": "Set Timer",
@@ -3888,8 +3886,7 @@
}
},
"status": {
"unhealthy": "Unhealthy",
"free": "{value}% free"
"unhealthy": "Unhealthy"
}
},
"customApi": {
@@ -4167,18 +4164,15 @@
},
"board": {
"mobile": {
"actions": "Actions for {title}",
"actions": "Widget actions",
"details": "Open widget details",
"empty": {
"title": "This board is empty",
"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."
"description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically."
},
"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,8 +5,6 @@ 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) => ({
+6 -15
View File
@@ -14,21 +14,16 @@ 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, displayMode }: WidgetComponentProps<"app">) {
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const {
data: app,
error,
isPending,
} = clientApi.app.byId.useQuery({
const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
useRegisterSpotlightContextResults(
@@ -53,14 +48,10 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
[app, options.openInNewTab],
);
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !app) throw error;
if (!app) return <WidgetEmptyState />;
const isMobileSummary = displayMode === "mobileSummary";
const isTiny = !isMobileSummary && (height < 100 || width < 100);
const layout = isMobileSummary ? "column" : options.layout;
const isColumnLayout = layout.startsWith("column");
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
return (
<AppLink
@@ -85,7 +76,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction={layout}
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
@@ -102,7 +93,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
{app.name}
</Text>
)}
{!isMobileSummary && options.descriptionDisplayMode === "normal" && (
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
queryKey: [["widget", "archiveTeamWarrior"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
@@ -14,8 +14,6 @@ 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,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
createOptions() {
@@ -13,7 +13,6 @@ 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,
@@ -36,7 +35,6 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
displayMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -70,16 +68,9 @@ 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" />
@@ -87,17 +78,6 @@ 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,14 +6,6 @@ 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,7 +39,6 @@ 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;
@@ -367,23 +366,7 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
);
};
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({
export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
@@ -6,13 +6,6 @@ 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, useState } from "react";
import { useCallback, useMemo } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
@@ -15,20 +15,9 @@ 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({ 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({
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
@@ -36,7 +25,6 @@ function BeszelSystemStatsContent({
boardId,
itemId,
setOptions,
isReadOnly = false,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -53,29 +41,18 @@ function BeszelSystemStatsContent({
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
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 selectedSystem = options.systemId || 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, isReadOnly, hasChangeAccess, boardId, itemId, saveItemOptions],
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
if (systemsError) throw systemsError;
@@ -163,7 +140,7 @@ function BeszelSystemStatsContent({
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={temporaryTimePeriod ?? (options.timePeriod as BeszelTimePeriod)}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
@@ -175,13 +152,7 @@ function BeszelSystemStatsContent({
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => {
if (isReadOnly) {
setTemporaryTimePeriod("1h");
return;
}
setOptions({ newOptions: { timePeriod: "1h" } });
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
</Stack>
</ScrollArea>
@@ -16,13 +16,6 @@ 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,7 +38,6 @@ 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 };
@@ -71,23 +70,7 @@ const getSizeConfig = (width: number): SizeConfig => {
};
};
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({
export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
@@ -25,13 +25,6 @@ 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,
@@ -1,44 +0,0 @@
"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,8 +10,6 @@ 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(),
+26 -41
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { Box, useMantineTheme } from "@mantine/core";
import { Center, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
@@ -13,7 +13,6 @@ 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";
@@ -41,11 +40,9 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !data) throw error;
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return (
<CalendarBase
@@ -55,7 +52,6 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth}
options={options}
displayMode={displayMode}
isStale={Boolean(error) || (data?.length ?? 0) < integrationIds.length}
/>
);
};
@@ -67,18 +63,9 @@ interface CalendarBaseProps {
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
isStale?: boolean;
}
const CalendarBase = ({
isEditMode,
events,
month,
setMonth,
options,
displayMode,
isStale = false,
}: CalendarBaseProps) => {
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -96,32 +83,30 @@ const CalendarBase = ({
.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 (
<Box
component="a"
href={link.href}
target="_blank"
rel="noopener noreferrer"
h="100%"
display="block"
style={{ color: "inherit", textDecoration: "none" }}
>
{summary}
</Box>
<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>
);
}
-1
View File
@@ -15,7 +15,6 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar"
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+11 -39
View File
@@ -1,57 +1,29 @@
"use client";
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Center, Group, Stack, Text } from "@mantine/core";
interface WidgetMobileSummaryProps {
value: number | string;
label: string;
description?: string;
isStale?: boolean;
}
export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) {
const t = useI18n();
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
return (
<Center h="100%" p="sm">
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
<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>
<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>
{description !== undefined && (
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1} ta="center" w="100%">
<Text c="dimmed" 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,10 +13,9 @@ interface ApplicationsSectionProps {
applications: CoolifyApplicationWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) {
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
@@ -35,14 +34,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDet
{applications.length > 0 ? (
<Stack gap={4}>
{applications.map((app) => (
<ResourceRow
key={app.uuid}
item={app}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="application"
isMobileDetail={isMobileDetail}
/>
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
))}
</Stack>
) : (
+4 -26
View File
@@ -10,12 +10,7 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -26,24 +21,16 @@ export default function CoolifyWidget({
);
}
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
isMobileDetail={displayMode === "mobileDetail"}
/>
);
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
isMobileDetail: boolean;
}
function CoolifyContent({ integrationIds, options, width, isMobileDetail }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
@@ -51,15 +38,7 @@ function CoolifyContent({ integrationIds, options, width, isMobileDetail }: Cool
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isMobileDetail={isMobileDetail}
/>
);
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
}
return (
@@ -72,7 +51,6 @@ function CoolifyContent({ integrationIds, options, width, isMobileDetail }: Cool
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
-2
View File
@@ -5,8 +5,6 @@ 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({
+3 -15
View File
@@ -18,10 +18,9 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isMobileDetail: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDetail }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -100,24 +99,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDet
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
<ApplicationsSection
applications={instance.instanceInfo.applications}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
+5 -38
View File
@@ -3,8 +3,6 @@
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 {
@@ -23,18 +21,11 @@ interface ResourceRowProps {
baseUrl: string;
isTiny: boolean;
resourceType: "application" | "service";
isMobileDetail: boolean;
}
export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetail }: ResourceRowProps) {
const tCommon = useScopedI18n("common");
const tApp = useScopedI18n("widget.app");
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
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
@@ -58,42 +49,18 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetai
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
{fqdn && (
<ActionIcon
component="a"
href={fqdn}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${openInNewTabLabel}: ${fqdn}`}
>
{cleanFqdn(item.fqdn) && (
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconLink size={12} />
</ActionIcon>
)}
{resourceUrl && (
<ActionIcon
component="a"
href={resourceUrl}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${openInNewTabLabel}: ${resourceLabel} ${item.name}`}
>
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
)}
{logsUrl && (
<ActionIcon
component="a"
href={logsUrl}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${tCommon("action.checkLogs")}: ${item.name}`}
>
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconFileText size={12} />
</ActionIcon>
)}
@@ -15,7 +15,6 @@ interface ServersSectionProps {
isTiny: boolean;
showIp: boolean;
onToggleIp: () => void;
isMobileDetail: boolean;
}
export function ServersSection({
@@ -25,7 +24,6 @@ export function ServersSection({
isTiny,
showIp,
onToggleIp,
isMobileDetail,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
@@ -42,11 +40,9 @@ export function ServersSection({
</Badge>
</Group>
<ActionIcon
size={isMobileDetail ? 44 : "xs"}
size="xs"
variant="subtle"
c="dimmed"
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
aria-pressed={showIp}
onClick={(event) => {
event.stopPropagation();
onToggleIp();
@@ -69,7 +65,6 @@ export function ServersSection({
baseUrl={baseUrl}
isTiny={isTiny}
showIp={showIp}
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
@@ -89,12 +84,10 @@ interface ServerRowProps {
baseUrl: string;
isTiny: boolean;
showIp: boolean;
isMobileDetail: boolean;
}
function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: ServerRowProps) {
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: 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}`;
@@ -117,15 +110,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }:
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<ActionIcon
component="a"
href={serverUrl}
target="_blank"
size={isMobileDetail ? 44 : "xs"}
variant="subtle"
c="dimmed"
aria-label={`${tApp("option.openInNewTab.label")}: ${server.name}`}
>
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
<Text fz="10px" c="dimmed">
@@ -13,10 +13,9 @@ interface ServicesSectionProps {
services: CoolifyServiceWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: ServicesSectionProps) {
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
@@ -35,14 +34,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: S
{services.length > 0 ? (
<Stack gap={4}>
{services.map((service) => (
<ResourceRow
key={service.uuid}
item={service}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="service"
isMobileDetail={isMobileDetail}
/>
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
))}
</Stack>
) : (
@@ -18,16 +18,9 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isMobileDetail: boolean;
}
export function SingleInstanceLayout({
instance,
options,
isTiny,
widgetKey,
isMobileDetail,
}: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -72,24 +65,13 @@ export function SingleInstanceLayout({
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
<ApplicationsSection
applications={instance.instanceInfo.applications}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
@@ -309,21 +309,14 @@ 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={openJsonLabel}>
<ActionIcon
variant="subtle"
size={44}
aria-label={openJsonLabel}
onClick={() => openJsonInBrowser(data.data)}
>
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
-2
View File
@@ -6,8 +6,6 @@ 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,19 +2,15 @@
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } 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);
@@ -30,8 +26,6 @@ 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">
@@ -40,24 +34,22 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size={44}
size="sm"
disabled={clampedPage === 0}
aria-label={tCommon("action.previous")}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<PreviousIcon size={14} />
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size={44}
size="sm"
disabled={clampedPage >= totalPages - 1}
aria-label={tCommon("action.next")}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<NextIcon size={14} />
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
+2 -5
View File
@@ -15,18 +15,16 @@ 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;
@@ -113,7 +111,6 @@ 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,21 +42,12 @@ 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={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} −`}
onClick={() => hoursHandlers.current?.decrement()}
>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
–
</ActionIcon>
<NumberInput
@@ -67,26 +58,15 @@ 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={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} +`}
onClick={() => hoursHandlers.current?.increment()}
>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} −`}
onClick={() => minutesHandlers.current?.decrement()}
>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
–
</ActionIcon>
<NumberInput
@@ -97,15 +77,9 @@ 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={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} +`}
onClick={() => minutesHandlers.current?.increment()}
>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
@@ -117,7 +91,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
mih={44}
h="2rem"
w="12rem"
onClick={handleSetTimer}
>
@@ -301,7 +301,6 @@ 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>
@@ -312,7 +311,6 @@ 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>
@@ -325,7 +323,6 @@ 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>
@@ -384,7 +381,6 @@ 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,7 +9,6 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
+9 -45
View File
@@ -16,7 +16,6 @@ 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 }) => {
@@ -36,7 +35,6 @@ const actionIconIconStyle: IconProps["style"] = {
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
actionIconSize: "xs" | 44,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
id: "name",
@@ -132,7 +130,6 @@ 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;
@@ -158,13 +155,12 @@ const createColumns = (
return (
<Group wrap="nowrap" gap="xs">
<Tooltip label={t(`action.${toggleAction}.label`)}>
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
<ActionIcon
variant="subtle"
size={actionIconSize}
size="xs"
radius="100%"
aria-label={`${t(`action.${toggleAction}.label`)}: ${row.original.name}`}
onClick={() => handleActionAsync(toggleAction)}
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
<IconPlayerStop style={actionIconIconStyle} />
@@ -174,13 +170,7 @@ const createColumns = (
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.restart.label")}>
<ActionIcon
variant="subtle"
size={actionIconSize}
radius="100%"
aria-label={`${t("action.restart.label")}: ${row.original.name}`}
onClick={() => handleActionAsync("restart")}
>
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
<IconRotateClockwise style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
@@ -190,38 +180,12 @@ const createColumns = (
},
];
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">) => {
export default function DockerWidget({ options, width, isEditMode }: 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 = useMemo(() => data?.containers ?? [], [data?.containers]);
const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -238,7 +202,7 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen
);
}, [containers]);
const columns = useMemo(() => createColumns(t, actionIconSize), [t, actionIconSize]);
const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
@@ -336,7 +300,7 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
size={displayMode === "mobileDetail" ? 44 : "sm"}
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
@@ -351,4 +315,4 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen
)}
</Stack>
);
};
}
-7
View File
@@ -25,13 +25,6 @@ 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) => ({
-2
View File
@@ -47,9 +47,7 @@ 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,8 +7,6 @@ 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,7 +8,6 @@ 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";
@@ -19,22 +18,11 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [], error, isPending } = clientApi.integration.byIds.useQuery(props.integrationIds);
const { data: integrations = [] } = 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} />;
}
@@ -47,79 +35,24 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
}
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>
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>
);
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,14 +8,6 @@ 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,12 +83,7 @@ export const SystemHealthMonitoring = ({
p="sm"
pos="relative"
>
<Box
className="health-monitoring-information-card-section"
pos="absolute"
top={8}
style={{ insetInlineStart: 8 }}
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
@@ -104,12 +99,10 @@ export const SystemHealthMonitoring = ({
className="health-monitoring-information-icon-avatar"
variant={"light"}
color="var(--mantine-color-text)"
size={44}
size="sm"
radius={board.itemRadius}
aria-label={t("widget.healthMonitoring.popover.information")}
onClick={open}
>
<IconInfoCircle className="health-monitoring-information-icon" size={24} />
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
</ActionIcon>
</Indicator>
<Modal
@@ -118,10 +111,6 @@ 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 />
+2 -16
View File
@@ -6,11 +6,10 @@ 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, displayMode }: WidgetComponentProps<"iframe">) {
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
@@ -20,11 +19,6 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
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%">
@@ -32,7 +26,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title={`${t("widget.iframe.name")}: ${frameLabel}`}
title="widget iframe"
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -45,14 +39,6 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
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,12 +5,6 @@ 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,8 +7,6 @@ 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 { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -37,13 +37,11 @@ export default function MediaMissingWidget({
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
const { data, error, isPending } = clientApi.widget.mediaOrganizer.getData.useQuery(
const { data } = 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)
@@ -66,14 +64,7 @@ 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}
isStale={Boolean(error)}
/>
);
return <WidgetMobileSummary value={primary.value} label={primary.label} description={description} />;
}
const isThin = width > 0 && width < 160;
@@ -15,7 +15,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaOrganizer"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
@@ -15,36 +15,23 @@ 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, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
const t = useI18n("widget.mediaReleases");
if (displayMode === "mobileSummary") {
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)}
/>
);
return <WidgetMobileSummary value={releases.length} label={t("name")} description={releases[0]?.title} />;
}
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 { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import { 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,11 +39,7 @@ export default function MediaServerWidget({
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList");
const {
data: mediaRequests,
error,
isPending,
} = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
@@ -52,20 +48,11 @@ 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}
isStale={Boolean(error)}
/>
);
return <WidgetMobileSummary value={mediaRequests.length} label={t("name")} description={mediaRequests[0]?.name} />;
}
return (
@@ -244,8 +231,7 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
size={44}
aria-label={t("pending.approve")}
size="xs"
onClick={() => {
handleDecision("approve");
}}
@@ -258,8 +244,7 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
className="mediaRequests-list-item-pending-button-decline"
variant="light"
color="red"
size={44}
aria-label={t("pending.decline")}
size="xs"
onClick={() => {
handleDecision("decline");
}}
@@ -17,7 +17,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaRequests"]],
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -39,7 +38,6 @@ 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,15 +7,12 @@ 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: () => {
+17 -72
View File
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { Fragment, useCallback, useMemo } from "react";
import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import {
IconDeviceTv,
@@ -24,47 +24,9 @@ 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({ 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">) => {
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
@@ -115,9 +77,7 @@ const MediaServerTable = ({
? 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 }}>
@@ -164,8 +124,8 @@ const MediaServerTable = ({
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 (
@@ -205,17 +165,6 @@ const MediaServerTable = ({
);
const { openModal } = useModalAction(ItemInfoModal);
const openSession = useCallback(
(session: StreamSession) => {
openModal(
{ item: session },
{
title: session.sessionName,
},
);
},
[openModal],
);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
@@ -261,21 +210,17 @@ const MediaServerTable = ({
height: "100%",
},
},
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);
mantineTableBodyCellProps: ({ row }) => ({
onClick: () => {
openModal(
{
item: row.original,
},
{
title: row.original.sessionName,
},
);
},
style: { cursor: "pointer" },
}),
mantineTableBodyCellProps: () => ({
py: 4,
style: {
overflowX: "hidden",
@@ -285,7 +230,7 @@ const MediaServerTable = ({
});
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
const session = flatSessions.find((candidate) => candidate.integrationKind === kind);
const session = flatSessions.find((session) => session.integrationKind === kind);
return {
integrationKind: kind,
integrationIcon: session?.integrationIcon,
@@ -317,7 +262,7 @@ const MediaServerTable = ({
</Group>
</Stack>
);
};
}
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
@@ -7,14 +7,6 @@ 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 { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -40,13 +40,11 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: queuePage,
};
const { data: transcodingData, error, isPending } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const { data: transcodingData } = 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") {
@@ -66,7 +64,6 @@ export default function MediaTranscodingWidget({
value={summary.value}
label={summary.label}
description={t(failedHealthChecks > 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")}
isStale={Boolean(error)}
/>
);
}
@@ -17,7 +17,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaTranscoding"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -2,9 +2,6 @@
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), {
@@ -12,23 +9,5 @@ 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,12 +6,6 @@ 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) => ({
+2 -9
View File
@@ -89,14 +89,7 @@ const controlIconProps = {
stroke: 1.5,
};
export function Notebook({
options,
setOptions,
isEditMode,
isReadOnly = false,
boardId,
itemId,
}: WidgetComponentProps<"notebook">) {
export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(content);
@@ -104,7 +97,7 @@ export function Notebook({
const { data: session } = useSession();
const { hasChangeAccess } = constructBoardPermissions(board, session);
const canChange = !isEditMode && !isReadOnly && hasChangeAccess;
const canChange = !isEditMode && hasChangeAccess;
const [isEditing, setIsEditing] = useState(false);
const { primaryColor } = useMantineTheme();
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Avatar, Box, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { Avatar, Box, Card, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -9,7 +9,6 @@ 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({
@@ -17,11 +16,7 @@ export default function NotificationsWidget({
integrationIds,
displayMode,
}: WidgetComponentProps<"notifications">) {
const {
data: notificationIntegrations,
error,
isPending,
} = clientApi.widget.notifications.getNotifications.useQuery({
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
@@ -32,39 +27,38 @@ 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 (
<Box
component="a"
href={latestNotification.href}
target="_blank"
rel="noopener noreferrer"
h="100%"
display="block"
style={{ color: "inherit", textDecoration: "none" }}
>
{summary}
</Box>
<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>
);
}
@@ -119,21 +113,22 @@ export default function NotificationsWidget({
);
}
const NotificationMobileSummary = ({
const NotificationSummary = ({
notification,
isStale,
}: {
notification: {
title: string | null;
body: string;
time: Date;
};
isStale: boolean;
}) => {
const timeAgo = useTimeAgo(notification.time, 30000);
return <WidgetMobileSummary value={notification.title ?? notification.body} label={timeAgo} isStale={isStale} />;
};
}) => (
<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>
);
const InfoDisplay = ({ date }: { date: Date }) => {
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
@@ -13,7 +13,6 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "notifications"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
+12 -38
View File
@@ -8,8 +8,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
mobile: { supportsDetailView: true },
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
createOptions() {
@@ -190,42 +188,18 @@ 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,8 +14,6 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
mobile: { supportsDetailView: true },
queryKey: [["widget", "releases"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+39 -45
View File
@@ -17,7 +17,7 @@ const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]
return feedEntries;
};
export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({ options, width }: WidgetComponentProps<"rssFeed">) {
const feedEntries = useLiveFeedEntries({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -29,55 +29,49 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
const isNarrow = width < 128 * 3;
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" />
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" />
)}
<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 }}
/>
<Group wrap="nowrap">
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
<Image src={feedEntry.enclosure} alt={feedEntry.title} w={140} h={140} radius="sm" />
)}
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Group>
</Card>
))}
</Stack>
);
<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 }}
/>
)}
if (displayMode === "mobileDetail") return feed;
return (
<ScrollArea className="scroll-area-w100" w="100%">
{feed}
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Group>
</Card>
))}
</Stack>
</ScrollArea>
);
}
-2
View File
@@ -12,8 +12,6 @@ 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,7 +13,6 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
supportsCompactSummary: true,
eager: true,
},
queryKey: [["widget", "smartHome"]],
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
mobile: { supportsDetailView: true },
queryKey: [["widget", "speedtestTracker"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
-2
View File
@@ -13,8 +13,6 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
mobile: { supportsDetailView: true },
queryKey: [["widget", "stockPrice"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more