Revert "feat: improve mobile board experience"
This reverts commit 7c0e98c55b.
This commit is contained in:
1 parent
c1819d6fdc
commit
e2732dce2c
45 files changed
+129
-1436
No files matched your search
@@ -83,13 +83,9 @@ Changing the column count automatically adjusts item positions and sizes to fit
|
||||
|
||||
#### Mobile layout
|
||||
|
||||
Phones use an automatic, read-only layout with exactly two columns in both portrait and landscape. Tablets use this layout at widths up to 48em. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high.
|
||||
Mobile boards use an automatic, read-only layout with two columns. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high.
|
||||
|
||||
Named categories and dynamic sections appear as lightweight headings and section shortcuts, but never as layout containers. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. Layout and widget configuration stay locked on mobile; links, refresh actions, automations, and widget details remain interactive.
|
||||
|
||||
App items use compact tiles. Widgets can opt into a compact summary, and larger widgets provide a full-screen detail view. Widgets farther down a long board are loaded shortly before they enter the viewport while their space is preserved with a skeleton.
|
||||
|
||||
To rearrange, add, remove, resize, or configure board items, open the board on a larger screen.
|
||||
Categories and dynamic sections are not displayed as containers on mobile. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. To rearrange, add, remove, or resize board items, open the board on a larger screen.
|
||||
|
||||
### Background
|
||||
#### Background image URL
|
||||
|
||||
@@ -44,7 +44,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
mobileProfileInActions: true,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect } 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";
|
||||
import { useDisclosure, useHotkeys, useReducedMotion } from "@mantine/hooks";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
@@ -17,18 +17,11 @@ import {
|
||||
IconReplace,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
IconDotsVertical,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconList,
|
||||
IconLogout,
|
||||
IconTool,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
@@ -39,18 +32,14 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
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 { 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";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
@@ -58,11 +47,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const isMobile = useIsMobileBoard();
|
||||
const t = useI18n();
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileMoreMenu showSettings={hasChangeAccess && !demoReadOnly} />;
|
||||
}
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
@@ -76,7 +60,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("item.menu.label.settings")}>
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
@@ -141,7 +125,7 @@ const AddMenu = () => {
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("common.action.add")}>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
@@ -180,7 +164,6 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
@@ -220,7 +203,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending} aria-label={tItem("action.edit")}>
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
@@ -229,14 +212,13 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.mobile.currentBoard")}>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
@@ -260,145 +242,6 @@ const SelectBoardsMenu = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const t = useI18n();
|
||||
const tProfile = useScopedI18n("common.userAvatar.menu");
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [opened, disclosure] = useDisclosure(false);
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const sections = useMemo(
|
||||
() => createMobileBoardElements(board, desktopLayout.id).filter((element) => element.type === "sectionHeading"),
|
||||
[board, desktopLayout.id],
|
||||
);
|
||||
|
||||
const jumpToSection = (anchorId: string) => {
|
||||
disclosure.close();
|
||||
requestAnimationFrame(() => {
|
||||
const heading = document.getElementById(anchorId);
|
||||
heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
|
||||
heading?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderButton onClick={disclosure.open} aria-label={t("board.mobile.more")}>
|
||||
<IconDotsVertical stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={disclosure.close}
|
||||
title={t("board.mobile.more")}
|
||||
position="bottom"
|
||||
size="auto"
|
||||
padding="md"
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
|
||||
>
|
||||
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
|
||||
<Button
|
||||
component={Link}
|
||||
href="/boards"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconHome size={20} />}
|
||||
>
|
||||
{tProfile("homeBoard")}
|
||||
</Button>
|
||||
<CurrentColorSchemeCombobox w="100%" />
|
||||
<CurrentLanguageCombobox width="100%" withinPortal={false} />
|
||||
{session ? (
|
||||
<>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/users/${session.user.id}/general`}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconUser size={20} />}
|
||||
>
|
||||
{tProfile("preferences")}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href="/manage"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconTool size={20} />}
|
||||
>
|
||||
{tProfile("management")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconLogout size={20} />}
|
||||
onClick={() => {
|
||||
void signOut({ redirect: false }).then(() => {
|
||||
window.location.assign(logoutUrl ?? "/auth/login");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tProfile("logout")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
component={Link}
|
||||
href="/auth/login"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconLogin size={20} />}
|
||||
>
|
||||
{tProfile("login")}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/boards/${board.name}/settings`}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconSettings size={20} />}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const t = useI18n();
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconCheck, IconChevronDown, IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
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";
|
||||
|
||||
export const BoardSwitcherLogo = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-start" width={280}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
aria-label={`${t("board.mobile.currentBoard")}: ${board.name}`}
|
||||
maw="min(42vw, 20rem)"
|
||||
mih={44}
|
||||
px={4}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<BoardLogo size={28} />
|
||||
<Text fw={600} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} aria-hidden />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("board.mobile.currentBoard")}</Menu.Label>
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
{boards.map((availableBoard) => {
|
||||
const isCurrent = availableBoard.id === board.id;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={availableBoard.id}
|
||||
component={Link}
|
||||
href={`/boards/${availableBoard.name}`}
|
||||
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
{availableBoard.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -3,16 +3,14 @@
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardOtherHeaderActions = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<HeaderButton href={`/boards/${board.name}`} aria-label={t("common.userAvatar.menu.homeBoard")}>
|
||||
<HeaderButton href={`/boards/${board.name}`}>
|
||||
<IconLayoutBoard stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { MobileBoardDeviceClass } from "~/components/board/use-mobile-board";
|
||||
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
@@ -21,7 +21,6 @@ import type { Params } from "./(content)/_creator";
|
||||
import { CustomCss } from "./(content)/_custom-css";
|
||||
import { BoardReadyProvider } from "./(content)/_ready-context";
|
||||
import { BoardMantineProvider } from "./(content)/_theme";
|
||||
import { BoardSwitcherLogo } from "./_board-switcher-logo";
|
||||
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
@@ -34,14 +33,12 @@ interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
mobileProfileInActions?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
mobileProfileInActions = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
const Layout = async ({
|
||||
params,
|
||||
@@ -68,36 +65,23 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
});
|
||||
const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]);
|
||||
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
|
||||
const initialDeviceClass: MobileBoardDeviceClass =
|
||||
deviceType === "mobile" ? "phone" : deviceType === "tablet" ? "tablet" : "desktop";
|
||||
const initialIsMobile = userAgent(new Headers(requestHeaders)).device.type === "mobile";
|
||||
|
||||
return (
|
||||
<MobileBoardViewportProvider initialDeviceClass={initialDeviceClass}>
|
||||
<MobileBoardViewportProvider initialIsMobile={initialIsMobile}>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false} withSafeArea>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardSwitcherLogo />}
|
||||
logoHref={null}
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
withSafeArea
|
||||
hideUserOnMobileBoard={mobileProfileInActions}
|
||||
/>
|
||||
<AppShellMain
|
||||
style={{
|
||||
paddingRight: "calc(var(--mantine-spacing-md) + env(safe-area-inset-right))",
|
||||
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
|
||||
paddingLeft: "calc(var(--mantine-spacing-md) + env(safe-area-inset-left))",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppShellMain>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</BoardMantineProvider>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { Viewport } from "next";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function BoardsLayout({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetDisplayMode } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
@@ -24,9 +23,6 @@ import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
displayMode?: WidgetDisplayMode;
|
||||
disableContextMenu?: boolean;
|
||||
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
@@ -35,55 +31,36 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const BoardItemContent = ({
|
||||
item,
|
||||
displayMode = "default",
|
||||
disableContextMenu = false,
|
||||
widgetStateRef: externalWidgetStateRef,
|
||||
}: BoardItemContentProps) => {
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const internalWidgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetStateRef = externalWidgetStateRef ?? internalWidgetStateRef;
|
||||
const content = (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
displayMode={displayMode}
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{disableContextMenu ? (
|
||||
content
|
||||
) : (
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
{content}
|
||||
</WidgetContextMenu>
|
||||
)}
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -113,11 +90,10 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
displayMode: WidgetDisplayMode;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -170,7 +146,6 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) =
|
||||
},
|
||||
})
|
||||
}
|
||||
displayMode={displayMode}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Skeleton } from "@mantine/core";
|
||||
|
||||
import classes from "./mobile-board.module.css";
|
||||
|
||||
interface DeferredMobileItemProps extends PropsWithChildren {
|
||||
eager: boolean;
|
||||
}
|
||||
|
||||
export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => {
|
||||
const [hasMounted, setHasMounted] = useState(eager);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (eager || hasMounted) return;
|
||||
|
||||
const element = wrapperRef.current;
|
||||
if (!element || typeof IntersectionObserver === "undefined") {
|
||||
setHasMounted(true);
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
|
||||
{hasMounted ? children : <Skeleton h="100%" w="100%" radius="var(--mantine-radius-md)" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +1,20 @@
|
||||
.stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: min(calc((100vw - 42px - env(safe-area-inset-left) - env(safe-area-inset-right)) / 2), 12rem);
|
||||
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
}
|
||||
|
||||
.deferredItem {
|
||||
.item > :global(.grid-stack-item-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.deferredItem > :global(.grid-stack-item-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
|
||||
padding-block: var(--mantine-spacing-xs);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: min(60dvh, 32rem);
|
||||
padding: var(--mantine-spacing-xl);
|
||||
}
|
||||
|
||||
.widgetAction {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.detailContent,
|
||||
.detailContent > :global(.grid-stack-item-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -2,98 +2,21 @@
|
||||
|
||||
import type { CSSProperties, RefObject } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { SectionProvider } from "../sections/section-context";
|
||||
import { DeferredMobileItem } from "./deferred-mobile-item";
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { MobileWidgetActions } from "./mobile-widget-actions";
|
||||
import {
|
||||
createMobileBoardElements,
|
||||
createMobileBoardItems,
|
||||
getMobileRootSection,
|
||||
mobileColumnCount,
|
||||
} from "./mobile-layout";
|
||||
import type { MobileBoardElement, MobileBoardSectionHeading } from "./mobile-layout";
|
||||
import { resolveMobileItemPresentation } from "./mobile-presentation";
|
||||
|
||||
type MobileBoardRenderBlock =
|
||||
| MobileBoardSectionHeading
|
||||
| {
|
||||
type: "items";
|
||||
id: string;
|
||||
items: SectionItem[];
|
||||
};
|
||||
|
||||
const createRenderBlocks = (elements: MobileBoardElement[]): MobileBoardRenderBlock[] => {
|
||||
const blocks: MobileBoardRenderBlock[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
if (element.type === "sectionHeading") {
|
||||
blocks.push(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousBlock = blocks.at(-1);
|
||||
if (previousBlock?.type === "items") {
|
||||
previousBlock.items.push(element);
|
||||
} else {
|
||||
blocks.push({ type: "items", id: `items-${element.id}`, items: [element] });
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const getMobilePresentation = (item: SectionItem) => {
|
||||
const definition = widgetImports[item.kind].definition;
|
||||
return resolveMobileItemPresentation(item, "mobile" in definition ? definition.mobile : undefined);
|
||||
};
|
||||
|
||||
const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const presentation = getMobilePresentation(item);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes.item}
|
||||
style={{ gridColumn: `span ${presentation.width}`, gridRow: `span ${presentation.height}` }}
|
||||
data-mobile-board-item={item.id}
|
||||
data-mobile-display-mode={presentation.displayMode}
|
||||
>
|
||||
<DeferredMobileItem eager={presentation.eager}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode={presentation.displayMode}
|
||||
disableContextMenu
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
<MobileWidgetActions
|
||||
item={item}
|
||||
supportsDetails={presentation.supportsDetails}
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</DeferredMobileItem>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout";
|
||||
|
||||
export const MobileBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const renderBlocks = useMemo(() => createRenderBlocks(elements), [elements]);
|
||||
const rootSection = getMobileRootSection(board);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||
@@ -109,47 +32,21 @@ export const MobileBoard = () => {
|
||||
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
|
||||
}}
|
||||
>
|
||||
<Box className={classes.stream} data-mobile-board>
|
||||
{elements.length === 0 ? (
|
||||
<Center className={classes.empty}>
|
||||
<Stack align="center" gap="xs" ta="center" maw={360}>
|
||||
<ThemeIcon size={48} radius="xl" variant="light">
|
||||
<IconLayoutBoard size={24} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} size="h3">
|
||||
{t("board.mobile.empty.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("board.mobile.empty.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
renderBlocks.map((block) =>
|
||||
block.type === "sectionHeading" ? (
|
||||
<Title
|
||||
key={block.id}
|
||||
id={block.anchorId}
|
||||
order={2}
|
||||
size="h4"
|
||||
className={classes.sectionHeading}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{block.title}
|
||||
</Title>
|
||||
) : (
|
||||
<Box
|
||||
key={block.id}
|
||||
className={classes.grid}
|
||||
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
|
||||
>
|
||||
{block.items.map((item) => (
|
||||
<MobileBoardItem key={item.id} item={item} />
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
)
|
||||
)}
|
||||
<Box
|
||||
className={classes.grid}
|
||||
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
|
||||
data-mobile-board
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
className={classes.item}
|
||||
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
|
||||
data-mobile-board-item={item.id}
|
||||
>
|
||||
<BoardItemContent item={item} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
</ReadOnlyEditModeProvider>
|
||||
|
||||
@@ -5,12 +5,7 @@ import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
|
||||
import {
|
||||
createMobileBoardElements,
|
||||
createMobileBoardItems,
|
||||
getMobileRootSection,
|
||||
getMobileSectionAnchorId,
|
||||
} from "./mobile-layout";
|
||||
import { createMobileBoardItems, getMobileRootSection } from "./mobile-layout";
|
||||
|
||||
describe("createMobileBoardItems", () => {
|
||||
test("selects the first root section in visual order", () => {
|
||||
@@ -98,86 +93,4 @@ describe("createMobileBoardItems", () => {
|
||||
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
|
||||
expect(result[1]).toMatchObject({ width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("emits named category and dynamic section headings in visual order", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
|
||||
const category = new CategorySectionMockBuilder({ id: "category", name: " Services ", yOffset: 0 }).build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
options: { title: "Monitoring", borderColor: "", customCssClasses: [] },
|
||||
})
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: category.id, xOffset: 1, yOffset: 0 })
|
||||
.build();
|
||||
const nestedDynamicSection = new DynamicSectionMockBuilder({
|
||||
id: "nested-dynamic",
|
||||
options: { title: "Details", borderColor: "", customCssClasses: [] },
|
||||
})
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
|
||||
.build();
|
||||
|
||||
board.sections.push(category, dynamicSection, nestedDynamicSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "before-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id, xOffset: 0, yOffset: 0 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id, xOffset: 0, yOffset: 0 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-nested" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createMobileBoardElements(board, desktopLayoutId);
|
||||
|
||||
expect(
|
||||
result.map((element) => (element.type === "sectionHeading" ? `heading:${element.title}` : `item:${element.id}`)),
|
||||
).toEqual([
|
||||
"heading:Services",
|
||||
"item:before-dynamic",
|
||||
"heading:Monitoring",
|
||||
"item:inside-dynamic",
|
||||
"heading:Details",
|
||||
"item:inside-nested",
|
||||
]);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "sectionHeading",
|
||||
sectionId: category.id,
|
||||
anchorId: getMobileSectionAnchorId(category.id),
|
||||
});
|
||||
});
|
||||
|
||||
test("omits headings for empty and unnamed sections while preserving their items", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
|
||||
const emptyNamedCategory = new CategorySectionMockBuilder({
|
||||
id: "empty-category",
|
||||
name: "Empty",
|
||||
yOffset: 0,
|
||||
}).build();
|
||||
const unnamedCategory = new CategorySectionMockBuilder({ id: "unnamed-category", name: " ", yOffset: 1 }).build();
|
||||
const unnamedDynamicSection = new DynamicSectionMockBuilder({ id: "unnamed-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id })
|
||||
.build();
|
||||
|
||||
board.sections.push(emptyNamedCategory, unnamedCategory, unnamedDynamicSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "inside-unnamed" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: unnamedDynamicSection.id })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "orphan" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section" })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createMobileBoardElements(board, desktopLayoutId);
|
||||
|
||||
expect(result.map((element) => element.type)).toEqual(["item", "item"]);
|
||||
expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Board, CategorySection, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const mobileColumnCount = 2;
|
||||
const mobileMaxHeight = 3;
|
||||
const mobileSectionAnchorPrefix = "mobile-board-section-";
|
||||
|
||||
type PositionedElement = DynamicSectionItem | SectionItem;
|
||||
type PositionedLayout = {
|
||||
@@ -13,16 +12,6 @@ type PositionedLayout = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type MobileBoardSectionHeading = {
|
||||
type: "sectionHeading";
|
||||
id: string;
|
||||
sectionId: string;
|
||||
title: string;
|
||||
anchorId: string;
|
||||
};
|
||||
|
||||
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
|
||||
|
||||
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
|
||||
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
|
||||
|
||||
@@ -39,8 +28,6 @@ export const getMobileRootSection = (board: Board) =>
|
||||
yOffset: 0,
|
||||
};
|
||||
|
||||
export const getMobileSectionAnchorId = (sectionId: string) => `${mobileSectionAnchorPrefix}${sectionId}`;
|
||||
|
||||
const getLayout = <TLayout extends PositionedLayout>(
|
||||
layouts: TLayout[],
|
||||
desktopLayoutId: string,
|
||||
@@ -51,20 +38,7 @@ 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 title = (section.kind === "category" ? section.name : section.options.title).trim();
|
||||
if (title.length === 0) return null;
|
||||
|
||||
return {
|
||||
type: "sectionHeading",
|
||||
id: `section-heading-${section.id}`,
|
||||
sectionId: section.id,
|
||||
title,
|
||||
anchorId: getMobileSectionAnchorId(section.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMobileBoardElements = (board: Board, desktopLayoutId: string): MobileBoardElement[] => {
|
||||
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
|
||||
const rootSections = getMobileRootSections(board);
|
||||
const defaultSectionId = rootSections[0]?.id ?? "mobile";
|
||||
const layoutPriority = new Map(
|
||||
@@ -106,66 +80,38 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
|
||||
elementsBySection.set(parentSectionId, elements);
|
||||
}
|
||||
|
||||
const result: MobileBoardElement[] = [];
|
||||
const result: SectionItem[] = [];
|
||||
const visitedItems = new Set<string>();
|
||||
const visitedSections = new Set<string>();
|
||||
|
||||
const createSectionElements = (
|
||||
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
|
||||
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
|
||||
if (visitedSections.has(section.id)) return { elements: [], items: [] };
|
||||
const sectionId = section.id;
|
||||
const appendSectionItems = (sectionId: string) => {
|
||||
if (visitedSections.has(sectionId)) return;
|
||||
visitedSections.add(sectionId);
|
||||
|
||||
const sectionElements: MobileBoardElement[] = [];
|
||||
const sectionItems: SectionItem[] = [];
|
||||
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
|
||||
if (element.type === "section") {
|
||||
const nestedResult = createSectionElements(element);
|
||||
sectionElements.push(...nestedResult.elements);
|
||||
sectionItems.push(...nestedResult.items);
|
||||
appendSectionItems(element.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visitedItems.has(element.id)) {
|
||||
visitedItems.add(element.id);
|
||||
sectionElements.push(element);
|
||||
sectionItems.push(element);
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionItems.length === 0) return { elements: [], items: [] };
|
||||
|
||||
const heading = section.kind === "empty" ? null : createSectionHeading(section);
|
||||
return {
|
||||
elements: heading ? [heading, ...sectionElements] : sectionElements,
|
||||
items: sectionItems,
|
||||
};
|
||||
};
|
||||
|
||||
rootSections.forEach((section) => result.push(...createSectionElements(section).elements));
|
||||
dynamicSections
|
||||
.toSorted(comparePosition)
|
||||
.forEach((section) => result.push(...createSectionElements(section).elements));
|
||||
rootSections.forEach((section) => appendSectionItems(section.id));
|
||||
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
|
||||
items.toSorted(comparePosition).forEach((item) => {
|
||||
if (!visitedItems.has(item.id)) result.push(item);
|
||||
});
|
||||
|
||||
let itemIndex = 0;
|
||||
return result.map((element) => {
|
||||
if (element.type === "sectionHeading") return element;
|
||||
|
||||
return {
|
||||
...element,
|
||||
xOffset: 0,
|
||||
yOffset: itemIndex++,
|
||||
width: Math.max(1, Math.min(element.width, mobileColumnCount)),
|
||||
height: Math.max(1, Math.min(element.height, mobileMaxHeight)),
|
||||
};
|
||||
});
|
||||
return result.map((item, index) => ({
|
||||
...item,
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
|
||||
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
|
||||
}));
|
||||
};
|
||||
|
||||
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] =>
|
||||
createMobileBoardElements(board, desktopLayoutId).filter(
|
||||
(element): element is SectionItem => element.type === "item",
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMobileItemPresentation } from "./mobile-presentation";
|
||||
|
||||
describe("resolveMobileItemPresentation", () => {
|
||||
test("renders apps as eager compact tiles", () => {
|
||||
expect(resolveMobileItemPresentation({ kind: "app", height: 8 }, undefined)).toMatchObject({
|
||||
width: 1,
|
||||
height: 1,
|
||||
eager: true,
|
||||
supportsDetails: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults widgets to full width and clamps desktop height", () => {
|
||||
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
|
||||
width: 2,
|
||||
height: 3,
|
||||
eager: false,
|
||||
supportsDetails: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers explicit presentation metadata", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "clock", height: 3 },
|
||||
{
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: false,
|
||||
eager: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
width: 1,
|
||||
height: 1,
|
||||
displayMode: "mobileSummary",
|
||||
supportsDetails: false,
|
||||
eager: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { WidgetMobilePresentation } from "@homarr/widgets";
|
||||
|
||||
export const resolveMobileItemPresentation = (
|
||||
item: { kind: WidgetKind; height: number },
|
||||
mobile: WidgetMobilePresentation | undefined,
|
||||
) => ({
|
||||
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",
|
||||
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { useItemActions } from "../items/item-actions";
|
||||
import classes from "./mobile-board.module.css";
|
||||
|
||||
interface MobileWidgetActionsProps {
|
||||
item: SectionItem;
|
||||
supportsDetails: boolean;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
|
||||
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
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 options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
[item.kind, item.options, settings],
|
||||
);
|
||||
const contextActions = useMemo(() => {
|
||||
if (!("contextActions" in definition) || typeof definition.contextActions !== "function") return [];
|
||||
|
||||
return definition.contextActions({
|
||||
options,
|
||||
setOptions: (partial: Record<string, unknown>) =>
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: { ...options, ...partial },
|
||||
}),
|
||||
integrationIds: item.integrationIds,
|
||||
context: { isEditMode, boardId: board.id, itemId: item.id },
|
||||
widgetStateRef,
|
||||
} as never) as WidgetContextMenuAction[];
|
||||
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
|
||||
const visibleContextActions = contextActions.filter((action) => !action.hidden);
|
||||
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
|
||||
|
||||
if (!hasActions) return null;
|
||||
|
||||
const refresh = () => {
|
||||
void queryClient.refetchQueries({ queryKey, type: "all" });
|
||||
actionsDisclosure.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionIcon
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions")}
|
||||
onClick={actionsDisclosure.open}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Drawer
|
||||
opened={actionsOpened}
|
||||
onClose={actionsDisclosure.close}
|
||||
title={title}
|
||||
position="bottom"
|
||||
size="auto"
|
||||
padding="md"
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
|
||||
>
|
||||
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
|
||||
{supportsDetails && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
actionsDisclosure.close();
|
||||
detailsDisclosure.open();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
</Button>
|
||||
)}
|
||||
{visibleContextActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="subtle"
|
||||
color={action.color}
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={action.disabled}
|
||||
leftSection={Icon ? <Icon size={20} /> : undefined}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</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>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={detailsOpened}
|
||||
onClose={detailsDisclosure.close}
|
||||
title={title}
|
||||
fullScreen
|
||||
padding="md"
|
||||
styles={{
|
||||
body: {
|
||||
height: "calc(100dvh - 4rem)",
|
||||
overflowY: "auto",
|
||||
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box className={classes.detailContent}>
|
||||
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu widgetStateRef={widgetStateRef} />
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMobileBoardViewport } from "./use-mobile-board";
|
||||
|
||||
describe("resolveMobileBoardViewport", () => {
|
||||
test("keeps phones mobile regardless of their viewport width", () => {
|
||||
expect(
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "phone",
|
||||
matchesMobileWidth: false,
|
||||
hasResolvedClientWidth: true,
|
||||
}),
|
||||
).toEqual({
|
||||
deviceClass: "phone",
|
||||
isMobile: true,
|
||||
isResolved: true,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
])("uses the 48em query for tablets (matches: %s)", (matchesMobileWidth, expectedIsMobile) => {
|
||||
expect(
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "tablet",
|
||||
matchesMobileWidth,
|
||||
hasResolvedClientWidth: true,
|
||||
}).isMobile,
|
||||
).toBe(expectedIsMobile);
|
||||
});
|
||||
|
||||
test("keeps tablets unresolved until their client width is known", () => {
|
||||
expect(
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "tablet",
|
||||
matchesMobileWidth: false,
|
||||
hasResolvedClientWidth: false,
|
||||
}).isResolved,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
])("keeps desktop resize behavior (matches: %s)", (matchesMobileWidth, expectedIsMobile) => {
|
||||
expect(
|
||||
resolveMobileBoardViewport({
|
||||
deviceClass: "desktop",
|
||||
matchesMobileWidth,
|
||||
hasResolvedClientWidth: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
isMobile: expectedIsMobile,
|
||||
isResolved: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,97 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { Box, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { createContext, useContext } from "react";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
export const mobileBoardMediaQuery = "(max-width: 48em)";
|
||||
|
||||
export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop";
|
||||
|
||||
interface MobileBoardViewport {
|
||||
deviceClass: MobileBoardDeviceClass;
|
||||
isMobile: boolean;
|
||||
isResolved: boolean;
|
||||
}
|
||||
|
||||
const MobileBoardViewportContext = createContext<MobileBoardViewport>({
|
||||
deviceClass: "desktop",
|
||||
isMobile: false,
|
||||
isResolved: true,
|
||||
});
|
||||
|
||||
export const resolveMobileBoardViewport = ({
|
||||
deviceClass,
|
||||
matchesMobileWidth,
|
||||
hasResolvedClientWidth,
|
||||
}: {
|
||||
deviceClass: MobileBoardDeviceClass;
|
||||
matchesMobileWidth: boolean;
|
||||
hasResolvedClientWidth: boolean;
|
||||
}): MobileBoardViewport => ({
|
||||
deviceClass,
|
||||
isMobile: deviceClass === "phone" || matchesMobileWidth,
|
||||
isResolved: deviceClass !== "tablet" || hasResolvedClientWidth,
|
||||
});
|
||||
|
||||
const MobileBoardViewportSkeleton = () => (
|
||||
<Box
|
||||
aria-busy
|
||||
mih="100dvh"
|
||||
p="md"
|
||||
pt="calc(var(--mantine-spacing-md) + env(safe-area-inset-top))"
|
||||
pr="calc(var(--mantine-spacing-md) + env(safe-area-inset-right))"
|
||||
pb="calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))"
|
||||
pl="calc(var(--mantine-spacing-md) + env(safe-area-inset-left))"
|
||||
>
|
||||
<Group h={44} justify="space-between" wrap="nowrap">
|
||||
<Skeleton h={32} w="min(45vw, 12rem)" />
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Skeleton circle h={44} />
|
||||
<Skeleton circle h={44} />
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="md" mt="md">
|
||||
<Group align="stretch" grow wrap="nowrap">
|
||||
<Skeleton h={150} />
|
||||
<Skeleton h={150} />
|
||||
</Group>
|
||||
<Skeleton h={220} />
|
||||
<Group align="stretch" grow wrap="nowrap">
|
||||
<Skeleton h={150} />
|
||||
<Skeleton h={150} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
|
||||
|
||||
export const MobileBoardViewportProvider = ({
|
||||
initialDeviceClass,
|
||||
initialIsMobile,
|
||||
children,
|
||||
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
|
||||
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
|
||||
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
|
||||
getInitialValueInEffect: true,
|
||||
});
|
||||
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
|
||||
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHasResolvedClientWidth(true);
|
||||
}, []);
|
||||
|
||||
const viewport = resolveMobileBoardViewport({
|
||||
deviceClass: initialDeviceClass,
|
||||
matchesMobileWidth,
|
||||
hasResolvedClientWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileBoardViewportContext.Provider value={viewport}>
|
||||
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
|
||||
</MobileBoardViewportContext.Provider>
|
||||
);
|
||||
export const useIsMobileBoard = () => {
|
||||
const initialIsMobile = useContext(InitialMobileBoardContext);
|
||||
return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
|
||||
};
|
||||
|
||||
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
|
||||
|
||||
export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
|
||||
@@ -6,49 +6,25 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { ResponsiveUserButton } from "./header/responsive-user";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
logoHref?: string | null;
|
||||
actions?: ReactNode;
|
||||
hasNavigation?: boolean;
|
||||
withSafeArea?: boolean;
|
||||
hideUserOnMobileBoard?: boolean;
|
||||
}
|
||||
|
||||
export const MainHeader = ({
|
||||
logo,
|
||||
logoHref = "/",
|
||||
actions,
|
||||
hasNavigation = true,
|
||||
withSafeArea = false,
|
||||
hideUserOnMobileBoard = false,
|
||||
}: Props) => {
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
return (
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex={201}
|
||||
style={{
|
||||
overflowX: "hidden",
|
||||
paddingTop: withSafeArea ? "env(safe-area-inset-top)" : undefined,
|
||||
paddingRight: withSafeArea ? "env(safe-area-inset-right)" : undefined,
|
||||
paddingLeft: withSafeArea ? "env(safe-area-inset-left)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Group h="100%" gap="md" px="md" justify="apart" wrap="nowrap">
|
||||
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
{logoHref === null ? (
|
||||
(logo ?? <HomarrLogoWithTitle size="md" />)
|
||||
) : (
|
||||
<UnstyledButton component={Link} href={logoHref}>
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
<UnstyledButton component={Link} href="/">
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
@@ -56,11 +32,9 @@ export const MainHeader = ({
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<ResponsiveUserButton hideOnMobileBoard={hideUserOnMobileBoard}>
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</ResponsiveUserButton>
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<Spotlight />
|
||||
|
||||
@@ -20,7 +20,7 @@ const headerButtonActionIconProps: ActionIconProps = {
|
||||
variant: "subtle",
|
||||
style: { border: "none" },
|
||||
color: "gray",
|
||||
size: 44,
|
||||
size: "lg",
|
||||
};
|
||||
|
||||
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((props, ref) => {
|
||||
@@ -29,15 +29,15 @@ export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((pr
|
||||
<ActionIcon
|
||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||
component={Link}
|
||||
{...headerButtonActionIconProps}
|
||||
{...props}
|
||||
{...headerButtonActionIconProps}
|
||||
>
|
||||
{props.children}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ActionIcon ref={ref} {...headerButtonActionIconProps} {...props}>
|
||||
<ActionIcon ref={ref} {...props} {...headerButtonActionIconProps}>
|
||||
{props.children}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { useIsMobileBoard } from "../../board/use-mobile-board";
|
||||
|
||||
export const ResponsiveUserButton = ({
|
||||
hideOnMobileBoard,
|
||||
children,
|
||||
}: PropsWithChildren<{ hideOnMobileBoard: boolean }>) => {
|
||||
const isMobileBoard = useIsMobileBoard();
|
||||
return hideOnMobileBoard && isMobileBoard ? null : children;
|
||||
};
|
||||
@@ -28,10 +28,8 @@ export const DesktopSearchInput = () => {
|
||||
};
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch} aria-label={t("search.placeholder")}>
|
||||
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
|
||||
@@ -4,20 +4,19 @@ import { UnstyledButton } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
export const UserButton = async () => {
|
||||
const [session, t] = await Promise.all([auth(), getI18n()]);
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton aria-label={t("board.mobile.profile")}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
<CurrentUserAvatar size="md" />
|
||||
|
||||
@@ -10,13 +10,11 @@ import { navigationCollapsedAtom } from "./header/burger";
|
||||
interface ClientShellProps {
|
||||
hasHeader?: boolean;
|
||||
hasNavigation?: boolean;
|
||||
withSafeArea?: boolean;
|
||||
}
|
||||
|
||||
export const ClientShell = ({
|
||||
hasHeader = true,
|
||||
hasNavigation = true,
|
||||
withSafeArea = false,
|
||||
children,
|
||||
}: PropsWithChildren<ClientShellProps>) => {
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
@@ -25,7 +23,7 @@ export const ClientShell = ({
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
header={hasHeader ? { height: withSafeArea ? "calc(60px + env(safe-area-inset-top))" : 60 } : undefined}
|
||||
header={hasHeader ? { height: 60 } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
|
||||
@@ -4163,18 +4163,6 @@
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"mobile": {
|
||||
"actions": "Widget actions",
|
||||
"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."
|
||||
},
|
||||
"more": "More",
|
||||
"profile": "Profile and account",
|
||||
"sections": "Jump to section",
|
||||
"currentBoard": "Current board"
|
||||
},
|
||||
"action": {
|
||||
"duplicate": {
|
||||
"title": "Duplicate board",
|
||||
@@ -4403,7 +4391,7 @@
|
||||
"description": "Configure the layout used on desktop and larger screens."
|
||||
},
|
||||
"mobile": {
|
||||
"description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers."
|
||||
"description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Categories and dynamic sections are flattened into the item list."
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
|
||||
@@ -15,12 +15,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||
icon: IconApps,
|
||||
mobile: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
eager: true,
|
||||
},
|
||||
createOptions(settings) {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Center, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import dayjs from "dayjs";
|
||||
@@ -11,7 +11,6 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
@@ -32,7 +31,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -44,16 +43,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
|
||||
};
|
||||
|
||||
interface CalendarBaseProps {
|
||||
@@ -62,15 +52,13 @@ interface CalendarBaseProps {
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("widget.calendar");
|
||||
const mantineTheme = useMantineTheme();
|
||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||
const { ref, width, height } = useElementSize();
|
||||
@@ -78,38 +66,6 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const nextEvent = normalizedEvents
|
||||
.filter((event) => event.startDate.getTime() >= Date.now())
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime())[0];
|
||||
const link = nextEvent?.links[0];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
|
||||
@@ -9,12 +9,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
|
||||
@@ -6,11 +6,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Center, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
interface WidgetMobileSummaryProps {
|
||||
value: number | string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
|
||||
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>
|
||||
{description !== undefined && (
|
||||
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -12,16 +12,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
|
||||
export type WidgetDisplayMode = "default" | "mobileSummary" | "mobileDetail";
|
||||
|
||||
export interface WidgetMobilePresentation {
|
||||
width: 1 | 2;
|
||||
height: 1 | 2 | 3;
|
||||
supportsCompactSummary?: boolean;
|
||||
supportsDetailView?: boolean;
|
||||
eager?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetContextMenuAction {
|
||||
key: string;
|
||||
label: stringOrTranslation;
|
||||
@@ -79,7 +69,6 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
mobile?: WidgetMobilePresentation;
|
||||
queryKey?: QueryKey;
|
||||
refetchInterval?: number | null;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
@@ -109,7 +98,6 @@ export interface WidgetProps<TKind extends WidgetKind> {
|
||||
|
||||
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
displayMode?: WidgetDisplayMode;
|
||||
isEditMode: boolean;
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: number;
|
||||
|
||||
@@ -42,12 +42,6 @@ const sortColumns = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -67,8 +67,6 @@ import * as customApi from "./custom-api";
|
||||
|
||||
export type {
|
||||
WidgetDefinition,
|
||||
WidgetDisplayMode,
|
||||
WidgetMobilePresentation,
|
||||
WidgetContextMenuAction,
|
||||
WidgetContextActionProps,
|
||||
WidgetOptionsSettings,
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -23,18 +9,11 @@ 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 type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function MediaMissingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const pageSize = Number(options.pageSize);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
||||
@@ -53,32 +32,25 @@ export default function MediaMissingWidget({
|
||||
</Center>
|
||||
);
|
||||
|
||||
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||
const missing = data.flatMap((entry) =>
|
||||
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const queued = data.flatMap((entry) =>
|
||||
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const primary = options.showMissing
|
||||
? { value: missingCount, label: t("tab.missing") }
|
||||
: { 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} />;
|
||||
}
|
||||
|
||||
const isThin = width > 0 && width < 160;
|
||||
const isShort = height > 0 && height < 180;
|
||||
const targetCardWidth = isShort ? 130 : 200;
|
||||
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
|
||||
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
|
||||
|
||||
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
|
||||
const tabLabel = (label: string, shown: number, total: number) =>
|
||||
isThin ? total : `${label} (${shown}/${total})`;
|
||||
|
||||
const renderPanel = (
|
||||
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
|
||||
emptyLabel: string,
|
||||
) => (
|
||||
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
|
||||
<ScrollArea h="100%" scrollbarSize={4}>
|
||||
<Box p="xs">
|
||||
{entries.length === 0 ? (
|
||||
@@ -144,14 +116,7 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeIcon
|
||||
className={classes.poster}
|
||||
h={size}
|
||||
w={w}
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={type === "movie" ? "yellow" : "blue"}
|
||||
>
|
||||
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
|
||||
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
@@ -218,25 +183,8 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
|
||||
);
|
||||
};
|
||||
|
||||
const CardShell = ({
|
||||
item,
|
||||
density,
|
||||
children,
|
||||
}: {
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component="a"
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
radius="sm"
|
||||
p="xs"
|
||||
h={CARD_HEIGHT[density]}
|
||||
>
|
||||
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
|
||||
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
|
||||
{item.imageUrl && (
|
||||
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
|
||||
)}
|
||||
|
||||
@@ -9,12 +9,6 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
|
||||
icon: IconMovie,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissing: factory.switch({ defaultValue: true }),
|
||||
|
||||
@@ -16,21 +16,11 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetMobileSummary } from "../common/mobile-summary";
|
||||
|
||||
export default function MediaReleasesWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
|
||||
const { data: releases = [] } = 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} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="sm">
|
||||
|
||||
@@ -5,12 +5,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
|
||||
icon: IconTicket,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
queryKey: [["widget", "mediaRelease"]],
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { WidgetMobileSummary } from "../../common/mobile-summary";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
@@ -36,9 +35,7 @@ export default function MediaServerWidget({
|
||||
isEditMode,
|
||||
options,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
integrationIds,
|
||||
statuses:
|
||||
@@ -51,10 +48,6 @@ export default function MediaServerWidget({
|
||||
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 (
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
|
||||
@@ -11,12 +11,6 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
icon: IconZoomQuestion,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { WidgetMobileSummary } from "../common/mobile-summary";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
@@ -31,7 +30,6 @@ export default function MediaTranscodingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const queuePageSize = 10;
|
||||
@@ -47,27 +45,6 @@ export default function MediaTranscodingWidget({
|
||||
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const summary =
|
||||
options.defaultView === "workers"
|
||||
? { value: transcodingData.data.workers.length, label: t("tab.workers") }
|
||||
: options.defaultView === "queue"
|
||||
? { value: transcodingData.data.queue.totalCount, label: t("tab.queue") }
|
||||
: {
|
||||
value: transcodingData.data.statistics.totalTranscodeCount,
|
||||
label: t("panel.statistics.transcodesCount"),
|
||||
};
|
||||
const failedHealthChecks = transcodingData.data.statistics.failedHealthCheckCount;
|
||||
|
||||
return (
|
||||
<WidgetMobileSummary
|
||||
value={summary.value}
|
||||
label={summary.label}
|
||||
description={t(failedHealthChecks > 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
const isTiny = width < 256;
|
||||
|
||||
|
||||
@@ -11,12 +11,6 @@ export const views = ["workers", "queue", "statistics"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -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, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -11,11 +11,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
|
||||
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
@@ -33,35 +29,6 @@ export default function NotificationsWidget({
|
||||
[notificationIntegrations],
|
||||
);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const latestNotification = sortedNotifications[0];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||
<Stack w={"100%"} gap="sm">
|
||||
@@ -113,23 +80,6 @@ export default function NotificationsWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationSummary = ({
|
||||
notification,
|
||||
}: {
|
||||
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>
|
||||
);
|
||||
|
||||
const InfoDisplay = ({ date }: { date: Date }) => {
|
||||
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
|
||||
|
||||
|
||||
@@ -7,12 +7,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||
icon: IconMessage,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -7,12 +7,6 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
|
||||
icon: IconBinaryTree,
|
||||
mobile: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
eager: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
|
||||
@@ -7,12 +7,6 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
|
||||
icon: IconBinaryTree,
|
||||
mobile: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
eager: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
displayName: factory.text(),
|
||||
|
||||
Reference in new issue
Block a user