feat: refine mobile dashboard experience
This commit is contained in:
1 parent
9f0969ec22
commit
315520beca
116 files changed
+2499
-586
No files matched your search
@@ -9,9 +9,9 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -22,24 +22,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
@@ -49,7 +54,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||

|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -57,6 +64,7 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -65,10 +73,12 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
@@ -92,41 +102,55 @@ App items use compact tiles. Widgets can opt into a compact summary, and larger
|
||||
To rearrange, add, remove, resize, or configure board items, open the board on a larger screen.
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

|
||||
|
||||
### Appearance
|
||||
|
||||
#### Primary color
|
||||
|
||||
Color that can be chosen freely to give accents to active elements.
|
||||
|
||||
#### Secondary color
|
||||
|
||||
Color that can be chosen freely to give accents.
|
||||
|
||||
#### Opacity
|
||||
|
||||
Opacity of the colors.
|
||||
|
||||
#### Icon color
|
||||
|
||||
Change the color of your bookmark / app icons.
|
||||
|
||||
<img src={iconColorImage} height={100} />
|
||||
<img src={iconColorImage} height={100} />
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
### Custom CSS
|
||||
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
@@ -135,14 +159,16 @@ The styling will only be applied on the respective board page.
|
||||
### Behaviour
|
||||
|
||||
#### Disable app status
|
||||
|
||||
This will disable the status check for apps.
|
||||
|
||||
### Access control
|
||||
|
||||
With the access control, you can permit a single user or a group to your board.
|
||||
The users and groups can have one of these three levels of access:
|
||||
|
||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||
|----------------------|------------|-----------|----------|--------------------------|
|
||||
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||
| View board | X | | | |
|
||||
| Modify board | X | X | X | |
|
||||
| Full access | X | X | X | X |
|
||||
@@ -161,18 +187,23 @@ After editing permissions, the save button below the table must be pressed.
|
||||
Otherwise, all changes will be discarded.
|
||||
|
||||
### Danger zone
|
||||
|
||||
#### Rename
|
||||
|
||||
Renaming a board will break all existing links to it permanently with no redirect.
|
||||
|
||||
#### Board visibility
|
||||
|
||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
||||
Making a board public will expose it to everyone - including unauthenticated users.
|
||||
|
||||
#### Delete
|
||||
|
||||
This will permanently delete the board.
|
||||
This action is irreversible and cannot be undone.
|
||||
|
||||
## Delete boards
|
||||
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
@@ -184,7 +215,7 @@ Both actions will execute the same deletion process.
|
||||
## Duplicate boards
|
||||
|
||||
:::note
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
@@ -194,47 +225,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
### For yourself
|
||||
|
||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
||||
|
||||

|
||||
|
||||
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`` \
|
||||

|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### 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`` \
|
||||

|
||||
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` \
|
||||

|
||||
|
||||
### 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`` \
|
||||

|
||||
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||
2. Select the desired home board via the dropdown and click on `Save` \
|
||||

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