Revert "feat: improve mobile board experience"

This reverts commit 7c0e98c55b.
This commit is contained in:
ajnart committed 2026-07-29 20:07:37 +02:00
1 parent c1819d6fdc
commit e2732dce2c
45 files changed
+129 -1436

No files matched your search

+2 -6
View File
@@ -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;
+9 -35
View File
@@ -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" />
+1 -3
View File
@@ -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
? {
+1 -13
View File
@@ -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": {
-6
View File
@@ -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) => ({
+4 -48
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { Center, Stack, Text, useMantineTheme } from "@mantine/core";
import { 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()}
-6
View File
@@ -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({
-5
View File
@@ -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
View File
@@ -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;
-6
View File
@@ -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(
-2
View File
@@ -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(),