feat: improve mobile board experience
This commit is contained in:
1 parent
7d5ec15017
commit
2e3dca3e87
28 files changed
+979
-258
No files matched your search
@@ -38,6 +38,7 @@ 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> }) => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"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,14 +3,16 @@
|
||||
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}`}>
|
||||
<HeaderButton href={`/boards/${board.name}`} aria-label={t("common.userAvatar.menu.homeBoard")}>
|
||||
<IconLayoutBoard stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { Viewport } from "next";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function BoardsLayout({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"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,20 +1,61 @@
|
||||
.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) / 2), 12rem);
|
||||
grid-auto-rows: min(calc((100vw - 42px - env(safe-area-inset-left) - env(safe-area-inset-right)) / 2), 12rem);
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
}
|
||||
|
||||
.item > :global(.grid-stack-item-content) {
|
||||
.deferredItem {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -2,21 +2,98 @@
|
||||
|
||||
import type { CSSProperties, RefObject } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
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 { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout";
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>>>({});
|
||||
@@ -32,21 +109,47 @@ export const MobileBoard = () => {
|
||||
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
|
||||
}}
|
||||
>
|
||||
<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 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>
|
||||
</SectionProvider>
|
||||
</ReadOnlyEditModeProvider>
|
||||
|
||||
@@ -5,7 +5,12 @@ 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 { createMobileBoardItems, getMobileRootSection } from "./mobile-layout";
|
||||
import {
|
||||
createMobileBoardElements,
|
||||
createMobileBoardItems,
|
||||
getMobileRootSection,
|
||||
getMobileSectionAnchorId,
|
||||
} from "./mobile-layout";
|
||||
|
||||
describe("createMobileBoardItems", () => {
|
||||
test("selects the first root section in visual order", () => {
|
||||
@@ -93,4 +98,86 @@ 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,7 +1,8 @@
|
||||
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, CategorySection, 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 = {
|
||||
@@ -12,6 +13,16 @@ 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;
|
||||
|
||||
@@ -28,6 +39,8 @@ export const getMobileRootSection = (board: Board) =>
|
||||
yOffset: 0,
|
||||
};
|
||||
|
||||
export const getMobileSectionAnchorId = (sectionId: string) => `${mobileSectionAnchorPrefix}${sectionId}`;
|
||||
|
||||
const getLayout = <TLayout extends PositionedLayout>(
|
||||
layouts: TLayout[],
|
||||
desktopLayoutId: string,
|
||||
@@ -38,7 +51,20 @@ const getLayout = <TLayout extends PositionedLayout>(
|
||||
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
|
||||
)[0];
|
||||
|
||||
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
|
||||
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[] => {
|
||||
const rootSections = getMobileRootSections(board);
|
||||
const defaultSectionId = rootSections[0]?.id ?? "mobile";
|
||||
const layoutPriority = new Map(
|
||||
@@ -80,38 +106,66 @@ export const createMobileBoardItems = (board: Board, desktopLayoutId: string): S
|
||||
elementsBySection.set(parentSectionId, elements);
|
||||
}
|
||||
|
||||
const result: SectionItem[] = [];
|
||||
const result: MobileBoardElement[] = [];
|
||||
const visitedItems = new Set<string>();
|
||||
const visitedSections = new Set<string>();
|
||||
|
||||
const appendSectionItems = (sectionId: string) => {
|
||||
if (visitedSections.has(sectionId)) return;
|
||||
const createSectionElements = (
|
||||
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
|
||||
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
|
||||
if (visitedSections.has(section.id)) return { elements: [], items: [] };
|
||||
const sectionId = section.id;
|
||||
visitedSections.add(sectionId);
|
||||
|
||||
const sectionElements: MobileBoardElement[] = [];
|
||||
const sectionItems: SectionItem[] = [];
|
||||
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
|
||||
if (element.type === "section") {
|
||||
appendSectionItems(element.id);
|
||||
const nestedResult = createSectionElements(element);
|
||||
sectionElements.push(...nestedResult.elements);
|
||||
sectionItems.push(...nestedResult.items);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visitedItems.has(element.id)) {
|
||||
visitedItems.add(element.id);
|
||||
result.push(element);
|
||||
sectionElements.push(element);
|
||||
sectionItems.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) => appendSectionItems(section.id));
|
||||
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
|
||||
rootSections.forEach((section) => result.push(...createSectionElements(section).elements));
|
||||
dynamicSections
|
||||
.toSorted(comparePosition)
|
||||
.forEach((section) => result.push(...createSectionElements(section).elements));
|
||||
items.toSorted(comparePosition).forEach((item) => {
|
||||
if (!visitedItems.has(item.id)) result.push(item);
|
||||
});
|
||||
|
||||
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)),
|
||||
}));
|
||||
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)),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] =>
|
||||
createMobileBoardElements(board, desktopLayoutId).filter(
|
||||
(element): element is SectionItem => element.type === "item",
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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"),
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
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,21 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { Box, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
export const mobileBoardMediaQuery = "(max-width: 48em)";
|
||||
|
||||
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
|
||||
export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop";
|
||||
|
||||
export const MobileBoardViewportProvider = ({
|
||||
initialIsMobile,
|
||||
children,
|
||||
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
|
||||
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
|
||||
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>
|
||||
);
|
||||
|
||||
export const useIsMobileBoard = () => {
|
||||
const initialIsMobile = useContext(InitialMobileBoardContext);
|
||||
return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
|
||||
export const MobileBoardViewportProvider = ({
|
||||
initialDeviceClass,
|
||||
children,
|
||||
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
|
||||
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
|
||||
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
|
||||
getInitialValueInEffect: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHasResolvedClientWidth(true);
|
||||
}, []);
|
||||
|
||||
const viewport = resolveMobileBoardViewport({
|
||||
deviceClass: initialDeviceClass,
|
||||
matchesMobileWidth,
|
||||
hasResolvedClientWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileBoardViewportContext.Provider value={viewport}>
|
||||
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
|
||||
</MobileBoardViewportContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
|
||||
|
||||
export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
|
||||
@@ -25,7 +25,7 @@ const headerButtonActionIconProps: ActionIconProps = {
|
||||
variant: "subtle",
|
||||
style: { border: "none" },
|
||||
color: "gray",
|
||||
size: "lg",
|
||||
size: 44,
|
||||
};
|
||||
|
||||
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((props, ref) => {
|
||||
@@ -34,15 +34,15 @@ export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((pr
|
||||
<ActionIcon
|
||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||
component={Link}
|
||||
{...props}
|
||||
{...headerButtonActionIconProps}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ActionIcon ref={ref} {...props} {...headerButtonActionIconProps}>
|
||||
<ActionIcon ref={ref} {...headerButtonActionIconProps} {...props}>
|
||||
{props.children}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"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;
|
||||
};
|
||||
@@ -5061,6 +5061,18 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"addContent": "Add board content",
|
||||
"settings": "Board settings",
|
||||
@@ -5305,7 +5317,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. Categories and dynamic sections are flattened into the item list."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
|
||||
@@ -10,13 +10,13 @@ import dayjs from "dayjs";
|
||||
|
||||
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 { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
splitEvents,
|
||||
toCalendarDateKey,
|
||||
} from "./calendar-events";
|
||||
import type { CalendarEventWithSource } from "./calendar-event-list";
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -54,7 +53,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
|
||||
events={[]}
|
||||
failedIntegrations={[]}
|
||||
isPending={false}
|
||||
queryError={undefined}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
@@ -81,20 +79,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
||||
const data = getUsableWidgetQueryData(calendarQuery);
|
||||
const { isPending } = calendarQuery;
|
||||
|
||||
const events = useMemo(
|
||||
() =>
|
||||
data?.flatMap(({ events: integrationEvents, integration, error }) =>
|
||||
error
|
||||
? []
|
||||
: integrationEvents.map(
|
||||
(event): CalendarEventWithSource => ({
|
||||
...event,
|
||||
source: { integrationId: integration.id, integrationName: integration.name },
|
||||
}),
|
||||
),
|
||||
) ?? [],
|
||||
[data],
|
||||
);
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
const failedIntegrations =
|
||||
data?.flatMap(({ integration, error }) =>
|
||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||
@@ -106,7 +91,6 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
||||
events={events}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
queryError={calendarQuery.error}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
@@ -117,10 +101,9 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
||||
|
||||
interface CalendarBaseProps {
|
||||
isEditMode: boolean;
|
||||
events: CalendarEventWithSource[];
|
||||
events: CalendarEvent[];
|
||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||
isPending: boolean;
|
||||
queryError: unknown;
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
@@ -132,13 +115,11 @@ const CalendarBase = ({
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
queryError,
|
||||
month,
|
||||
setMonth,
|
||||
options,
|
||||
displayMode,
|
||||
}: CalendarBaseProps) => {
|
||||
const t = useI18n();
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
@@ -170,8 +151,6 @@ const CalendarBase = ({
|
||||
events={agendaEvents}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
queryError={queryError}
|
||||
queryErrorLabel={t("widget.calendar.name")}
|
||||
isEditMode={isEditMode}
|
||||
locale={locale}
|
||||
month={month}
|
||||
@@ -182,10 +161,9 @@ const CalendarBase = ({
|
||||
|
||||
return (
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
||||
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group px="xs" justify="flex-end">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={t("widget.calendar.name")} />
|
||||
</Group>
|
||||
)}
|
||||
<Calendar
|
||||
@@ -265,11 +243,9 @@ const CalendarBase = ({
|
||||
};
|
||||
|
||||
interface CalendarAgendaProps {
|
||||
events: CalendarEventWithSource[];
|
||||
events: CalendarEvent[];
|
||||
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
||||
isPending: boolean;
|
||||
queryError: unknown;
|
||||
queryErrorLabel: string;
|
||||
isEditMode: boolean;
|
||||
locale: string;
|
||||
month: Date;
|
||||
@@ -280,8 +256,6 @@ const CalendarAgenda = ({
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
queryError,
|
||||
queryErrorLabel,
|
||||
isEditMode,
|
||||
locale,
|
||||
month,
|
||||
@@ -327,10 +301,7 @@ const CalendarAgenda = ({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
|
||||
</Group>
|
||||
{failedIntegrations.length > 0 && <IntegrationErrorIndicator results={failedIntegrations} />}
|
||||
</Group>
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
@@ -339,7 +310,7 @@ const CalendarAgenda = ({
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : events.length > 0 ? (
|
||||
<CalendarEventList events={events} advanced groupByDate locale={locale} fillHeight />
|
||||
<CalendarEventList events={events} groupByDate locale={locale} fillHeight />
|
||||
) : (
|
||||
<Stack h="100%" align="center" justify="center" p="md">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "weather", "atLocation"]],
|
||||
queryMatcher: ({ input }, scope) => {
|
||||
const location = scope.options.weatherLocation;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -53,18 +53,11 @@ import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { formatLocalizedDateTime } from "../common/locale";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import {
|
||||
DOWNLOAD_COLUMN_ACCESSORS,
|
||||
filterDownloadItemsByStatus,
|
||||
getAvailableDownloadStates,
|
||||
getDownloadColumnAccessors,
|
||||
} from "./helpers";
|
||||
import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -91,7 +84,6 @@ interface ColumnContext {
|
||||
hasTorrents: boolean;
|
||||
hasMultipleClients: boolean;
|
||||
hasMultipleTypes: boolean;
|
||||
isAdvanced: boolean;
|
||||
size: SizeConfig;
|
||||
}
|
||||
|
||||
@@ -151,8 +143,8 @@ const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boo
|
||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||
ratio: (ctx) => ctx.hasTorrents,
|
||||
sent: (ctx) => ctx.hasTorrents,
|
||||
integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients,
|
||||
type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes,
|
||||
integration: (ctx) => ctx.hasMultipleClients,
|
||||
type: (ctx) => ctx.hasMultipleTypes,
|
||||
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
||||
time: (ctx) => ctx.size.showTimeColumn,
|
||||
state: (ctx) => ctx.size.showStateColumn,
|
||||
@@ -179,6 +171,24 @@ function progressColor(state: DownloadState, progress: number): string {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
const columnAccessors = [
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
] as const;
|
||||
|
||||
function toCategoryArray(category: string | string[]): string[] {
|
||||
if (Array.isArray(category)) return category;
|
||||
return [category];
|
||||
@@ -310,17 +320,10 @@ export default function DownloadClientsWidget({
|
||||
integrationIds,
|
||||
limitPerIntegration: options.limitPerIntegration,
|
||||
});
|
||||
const currentItems = getUsableWidgetQueryData(downloadsQuery);
|
||||
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
|
||||
const currentItems = getUsableWidgetQueryData(downloadsQuery) ?? [];
|
||||
const { isFetching } = downloadsQuery;
|
||||
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={currentItems ?? []} />
|
||||
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
@@ -367,7 +370,7 @@ export default function DownloadClientsWidget({
|
||||
}, [options.defaultSort, defaultSortDirection]);
|
||||
|
||||
const eligibleData = useMemo<ExtendedDownloadClientItem[]>(() => {
|
||||
return availableItems
|
||||
return currentItems
|
||||
.filter(({ integration }) => integrationIds.includes(integration.id))
|
||||
.flatMap((pair) =>
|
||||
pair.data.items
|
||||
@@ -396,7 +399,7 @@ export default function DownloadClientsWidget({
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
availableItems,
|
||||
currentItems,
|
||||
integrationIds,
|
||||
integrationInteractSet,
|
||||
mutateDeleteItem,
|
||||
@@ -419,7 +422,7 @@ export default function DownloadClientsWidget({
|
||||
}, [data, sortStatus]);
|
||||
|
||||
const clients = useMemo<ExtendedClientStatus[]>(() => {
|
||||
return availableItems
|
||||
return currentItems
|
||||
.filter(({ integration }) => integrationIds.includes(integration.id))
|
||||
.map(({ integration, data: clientData }): ExtendedClientStatus => {
|
||||
const interact = integrationInteractSet.has(integration.id);
|
||||
@@ -456,7 +459,7 @@ export default function DownloadClientsWidget({
|
||||
};
|
||||
});
|
||||
}, [
|
||||
availableItems,
|
||||
currentItems,
|
||||
integrationIds,
|
||||
integrationInteractSet,
|
||||
options.applyFilterToRatio,
|
||||
@@ -469,13 +472,10 @@ export default function DownloadClientsWidget({
|
||||
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
|
||||
|
||||
const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
|
||||
const optionsColumnSet = useMemo(
|
||||
() => new Set<string>(getDownloadColumnAccessors(options.columns, isAdvanced)),
|
||||
[isAdvanced, options.columns],
|
||||
);
|
||||
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
|
||||
const columnContext = useMemo<ColumnContext>(
|
||||
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }),
|
||||
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size],
|
||||
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
|
||||
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size],
|
||||
);
|
||||
|
||||
let progressColumnWidth = 120;
|
||||
@@ -688,7 +688,7 @@ export default function DownloadClientsWidget({
|
||||
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
columnAccessors: DOWNLOAD_COLUMN_ACCESSORS,
|
||||
columnAccessors,
|
||||
columnOrder: options.columnOrder,
|
||||
columnWidths: options.columnWidths,
|
||||
itemId,
|
||||
@@ -733,16 +733,11 @@ export default function DownloadClientsWidget({
|
||||
return { stateCounts, totalSize, completedSize, totalItems: data.length };
|
||||
}, [data]);
|
||||
|
||||
if (!isAdvanced && options.columns.length === 0) {
|
||||
if (options.columns.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<Center h="100%">
|
||||
<Text>{t("errors.noColumns")}</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
<Center h="100%">
|
||||
<Text>{t("errors.noColumns")}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -764,13 +759,10 @@ export default function DownloadClientsWidget({
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding={`${size.cellPadding}px 8px`}
|
||||
fetching={isFetching && (currentItems?.length ?? 0) === 0}
|
||||
fetching={isFetching && currentItems.length === 0}
|
||||
fz={size.fontSize}
|
||||
records={sortedData}
|
||||
columns={effectiveColumns}
|
||||
|
||||
@@ -2,10 +2,28 @@ import { IconDownload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { DOWNLOAD_COLUMN_ACCESSORS } from "./helpers";
|
||||
|
||||
const columnsList = [
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
||||
|
||||
const sortColumns = [
|
||||
"name",
|
||||
@@ -20,7 +38,7 @@ const sortColumns = [
|
||||
"sent",
|
||||
"index",
|
||||
"type",
|
||||
] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][];
|
||||
] as const satisfies readonly (typeof columnsList)[number][];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
@@ -37,7 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
||||
(factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: ["name", "progress", "downSpeed", "time", "state"],
|
||||
options: DOWNLOAD_COLUMN_ACCESSORS.map((value) => ({
|
||||
options: columnsList.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||
})),
|
||||
|
||||
@@ -69,6 +69,8 @@ import * as wud from "./wud";
|
||||
export type {
|
||||
NormalizedWidgetQuery,
|
||||
WidgetDefinition,
|
||||
WidgetDisplayMode,
|
||||
WidgetMobilePresentation,
|
||||
WidgetContextMenuAction,
|
||||
WidgetContextActionProps,
|
||||
WidgetOptionsSettings,
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
@@ -46,19 +45,18 @@ export default function MediaMissingWidget({
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const showMissing = isAdvanced || options.showMissing;
|
||||
const showQueued = isAdvanced || options.showQueued;
|
||||
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
|
||||
const input = { integrationIds, pageSize };
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]);
|
||||
const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const data = getUsableWidgetQueryData(mediaQuery);
|
||||
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(showMissing ? "missing" : "queued");
|
||||
const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued);
|
||||
const data = getUsableWidgetQueryData(
|
||||
clientApi.widget.mediaOrganizer.getData.useQuery(input, {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
}),
|
||||
);
|
||||
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
|
||||
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
|
||||
@@ -66,7 +64,7 @@ export default function MediaMissingWidget({
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
if (data.length === 0) throw new NoIntegrationDataError();
|
||||
if (!showMissing && !showQueued)
|
||||
if (!options.showMissing && !options.showQueued)
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
@@ -81,7 +79,7 @@ export default function MediaMissingWidget({
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
|
||||
|
||||
const enabledPanelCount = Number(showMissing) + Number(showQueued);
|
||||
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
|
||||
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
|
||||
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
|
||||
const isShort = !isAdvanced && height > 0 && height < 180;
|
||||
@@ -104,12 +102,7 @@ export default function MediaMissingWidget({
|
||||
) : (
|
||||
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
|
||||
{entries.map(({ item, integrationId }) => (
|
||||
<MediaCard
|
||||
key={`${integrationId}-${item.type}-${item.id}`}
|
||||
item={item}
|
||||
density={density}
|
||||
showQueueDetails={isAdvanced}
|
||||
/>
|
||||
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -128,17 +121,13 @@ export default function MediaMissingWidget({
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
{partialFailures}
|
||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{showMissing && (
|
||||
{options.showMissing && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconQuestionMark size={16} />
|
||||
@@ -149,7 +138,7 @@ export default function MediaMissingWidget({
|
||||
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
|
||||
</Paper>
|
||||
)}
|
||||
{showQueued && (
|
||||
{options.showQueued && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconDownload size={16} />
|
||||
@@ -174,29 +163,26 @@ export default function MediaMissingWidget({
|
||||
h="100%"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
{partialFailures}
|
||||
<Tabs.List grow>
|
||||
{showMissing && (
|
||||
{options.showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{showQueued && (
|
||||
{options.showQueued && (
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
{showMissing && (
|
||||
{options.showMissing && (
|
||||
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(missing, t("empty.missing"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{showQueued && (
|
||||
{options.showQueued && (
|
||||
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(queued, t("empty.queued"))}
|
||||
</Tabs.Panel>
|
||||
@@ -320,15 +306,7 @@ const CardShell = ({
|
||||
);
|
||||
};
|
||||
|
||||
const MediaCard = ({
|
||||
item,
|
||||
density,
|
||||
showQueueDetails,
|
||||
}: {
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
showQueueDetails: boolean;
|
||||
}) => {
|
||||
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
|
||||
const isQueued = "percentComplete" in item;
|
||||
|
||||
return (
|
||||
@@ -345,11 +323,6 @@ const MediaCard = ({
|
||||
{item.type === "episode" ? item.title : item.year}
|
||||
</Text>
|
||||
)}
|
||||
{isQueued && showQueueDetails && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1} lh={1.1}>
|
||||
{[item.status, item.timeLeft].filter(Boolean).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
|
||||
</Group>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { OverflowBadge } from "@homarr/ui";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
@@ -45,43 +44,36 @@ export default function MediaReleasesWidget({
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const t = useI18n();
|
||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||
const response = getUsableWidgetQueryData(releasesQuery);
|
||||
const releases = getUsableWidgetQueryData(releasesQuery);
|
||||
|
||||
if (isInitialWidgetQueryPending(releasesQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
const { releases, failedIntegrations } = response;
|
||||
if (!releases || releases.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
{releases.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{(failedIntegrations.length > 0 || releasesQuery.error) && (
|
||||
<Group pos="absolute" top={4} right={4} gap={0}>
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
{releasesQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
|
||||
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||
isAdvanced: boolean;
|
||||
width: number;
|
||||
@@ -219,7 +211,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
/>
|
||||
)}
|
||||
{isAdvanced && item.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -238,17 +230,9 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{(isAdvanced || options.showSource) &&
|
||||
(isAdvanced ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt="" />
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{item.integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
|
||||
))}
|
||||
{(isAdvanced || options.showSource) && (
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -27,7 +26,6 @@ import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
@@ -68,8 +66,7 @@ export default function MediaTranscodingWidget({
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [
|
||||
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
|
||||
]);
|
||||
const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
const transcodingData = getUsableWidgetQueryData(transcodingQuery);
|
||||
const transcodingData = getUsableWidgetQueryData(clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input));
|
||||
|
||||
const [view, setView] = useState<View>(options.defaultView);
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
@@ -97,16 +94,10 @@ export default function MediaTranscodingWidget({
|
||||
const queuePageCount = totalQueuePages ?? 1;
|
||||
const isTiny = !isAdvanced && (width < 280 || height < 140);
|
||||
const footerLayout = getTranscodingFooterLayout(width, height);
|
||||
const queryIndicator = (
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
||||
{queryIndicator}
|
||||
<Stack gap="xs" h="100%" p="xs">
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
|
||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||
@@ -136,8 +127,7 @@ export default function MediaTranscodingWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%" pos="relative">
|
||||
{queryIndicator}
|
||||
<Stack gap={4} h="100%">
|
||||
{view === "workers" ? (
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
|
||||
) : view === "queue" ? (
|
||||
|
||||
@@ -9,25 +9,21 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getNotificationDisplay } from "./display";
|
||||
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
});
|
||||
const notificationData = getUsableWidgetQueryData(notificationsQuery);
|
||||
const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]);
|
||||
const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? [];
|
||||
const { isPending } = notificationsQuery;
|
||||
|
||||
const t = useI18n();
|
||||
@@ -53,23 +49,12 @@ export default function NotificationsWidget({
|
||||
const isDense = width < 280 || height < 180;
|
||||
const isRoomy = width >= 360 && height >= 220;
|
||||
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
|
||||
const notificationDisplay = getNotificationDisplay({
|
||||
displayMode,
|
||||
hideLogos: options.hideLogos,
|
||||
isRoomy,
|
||||
bodyLineClamp,
|
||||
});
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||
<Stack w="100%" gap="xs">
|
||||
{notificationsQuery.error && (
|
||||
<Group justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("widget.notifications.name")} />
|
||||
</Group>
|
||||
)}
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
@@ -110,7 +95,7 @@ export default function NotificationsWidget({
|
||||
}}
|
||||
>
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
{notificationDisplay.showLogos && notification.source?.iconUrl && (
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
@@ -128,7 +113,7 @@ export default function NotificationsWidget({
|
||||
<Text
|
||||
c="dimmed"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
lineClamp={notificationDisplay.bodyLineClamp}
|
||||
lineClamp={bodyLineClamp}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{notification.body}
|
||||
@@ -136,11 +121,7 @@ export default function NotificationsWidget({
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={
|
||||
notificationDisplay.showSource
|
||||
? (notification.source?.name ?? notification.integrationName)
|
||||
: undefined
|
||||
}
|
||||
source={isRoomy ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
dense={isDense}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||
supportsAdvancedFocus: true,
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconMessage,
|
||||
queryKey: [["widget", "notifications", "getNotifications"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
|
||||
Reference in new issue
Block a user