Compare commits

..
Author SHA1 Message Date
ajnart 79269aa055 fix: align data table scrollbars 2026-08-23 01:44:06 +02:00
ajnart 94e784f81f fix: cover all widget scroll areas 2026-08-23 01:16:00 +02:00
ajnart 60ffc34e73 fix: unify widget scroll areas 2026-08-23 01:09:02 +02:00
51 changed files with 255 additions and 549 deletions

No files matched your search

@@ -194,15 +194,11 @@ To configure this, click on the small 3 dots at the top of the item in the edit
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption> <figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
</figure> </figure>
The side editor keeps the selected tile visible while you choose the app and adjust its display settings: In the modal you can edit the app that you want to display:
<figure> <figure>
<img <img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
src={editItemCurrent} <figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
alt="App tile highlighted beside the Edit item side editor"
style={{ maxWidth: "620px" }}
/>
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</figcaption>
</figure> </figure>
As soon as you save your changes, the tile on the board will refresh and display your app. As soon as you save your changes, the tile on the board will refresh and display your app.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 68 KiB

@@ -1,6 +1,6 @@
"use client"; "use client";
import { use, useCallback, useId, useState } from "react"; import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const previewId = useId();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
@@ -59,7 +58,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
preloadWidgetEditModal(); preloadWidgetEditModal();
try { try {
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
const originalState = state;
openWidgetEditModal( openWidgetEditModal(
{ {
kind, kind,
@@ -68,14 +66,11 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
onSuccessfulEdit: (value) => { onSuccessfulEdit: (value) => {
setState(value); setState(value);
}, },
onPreviewChange: setState,
onPreviewRestore: () => setState(originalState),
integrationData: integrationData.filter((integration) => integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind), (currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
), ),
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
focusTargetId: previewId,
}, },
{ {
title(translate) { title(translate) {
@@ -91,7 +86,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally { } finally {
setIsEditorLoading(false); setIsEditorLoading(false);
} }
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]); }, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode); setEditMode((editMode) => !editMode);
@@ -115,7 +110,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
return ( return (
<> <>
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}> <Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
@@ -30,13 +30,6 @@ interface UpdateItemIntegrations {
newIntegrations: string[]; newIntegrations: string[];
} }
interface UpdateItemSettings {
itemId: string;
options: Record<string, unknown>;
advancedOptions: BoardItemAdvancedOptions;
integrationIds: string[];
}
export const useItemActions = () => { export const useItemActions = () => {
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
@@ -84,20 +77,6 @@ export const useItemActions = () => {
[updateBoard], [updateBoard],
); );
const updateItemSettings = useCallback(
({ itemId, options, advancedOptions, integrationIds }: UpdateItemSettings) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) => {
if (item.id !== itemId || !("integrationIds" in item)) return item;
return { ...item, options, advancedOptions, integrationIds };
}),
}));
},
[updateBoard],
);
const moveAndResizeItem = useCallback( const moveAndResizeItem = useCallback(
(input: Omit<MoveAndResizeItemInput, "layoutId">) => { (input: Omit<MoveAndResizeItemInput, "layoutId">) => {
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId })); updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
@@ -126,7 +105,6 @@ export const useItemActions = () => {
updateItemOptions, updateItemOptions,
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
updateItemSettings,
duplicateItem, duplicateItem,
createItem, createItem,
}; };
@@ -297,7 +297,6 @@ const LoadedBoardItemContent = ({
role={isPreview ? "region" : undefined} role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined} aria-label={isPreview ? advancedViewLabel : undefined}
data-advanced-focus-surface={isPreview || undefined} data-advanced-focus-surface={isPreview || undefined}
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
data-grid-item-content data-grid-item-content
radius={board.itemRadius} radius={board.itemRadius}
p={isAdvanced ? undefined : 0} p={isAdvanced ? undefined : 0}
@@ -3,7 +3,6 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions"; import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -34,7 +33,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
}; };
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const board = useRequiredBoard();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false); const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useI18n("item"); const tItem = useI18n("item");
@@ -42,7 +40,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal(); const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemSettings, duplicateItem, removeItem } = useItemActions(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData, section } = useSectionContext(); const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings(); const settings = useSettings();
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t); const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
@@ -56,11 +55,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
}, [item, resetErrorBoundary]); }, [item, resetErrorBoundary]);
const openEditModal = () => { const openEditModal = () => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -70,14 +64,19 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
options: item.options, options: item.options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onPreviewChange: (value) => { onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemSettings({ itemId: item.id, ...value }); updateItemOptions({
}, itemId: item.id,
onPreviewRestore: () => { newOptions: options,
updateItemSettings({ itemId: item.id, ...originalSettings }); });
}, updateItemAdvancedOptions({
onSuccessfulEdit: (value) => { itemId: item.id,
updateItemSettings({ itemId: item.id, ...value }); newAdvancedOptions: advancedOptions,
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true; refResetErrorBoundaryOnNextRender.current = true;
}, },
onIntegrationSaved: resetErrorBoundary, onIntegrationSaved: resetErrorBoundary,
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition, integrationSupport: "supportedIntegrations" in definition,
settings, settings,
itemId: item.id, itemId: item.id,
boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
@@ -277,7 +277,6 @@ const ItemSelectModalContent = ({
integrationData: [], integrationData: [],
integrationSupport: false, integrationSupport: false,
settings, settings,
boardId: board.id,
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -347,7 +346,6 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations, integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
boardId: board.id,
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`, title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
@@ -157,8 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector",
withOverlay: false,
size: modalSizeForm, size: modalSizeForm,
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
const tCommon = useI18n("common.action"); const tCommon = useI18n("common.action");
const settings = useSettings(); const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemSettings } = useItemActions(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0; const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations, enabled: hasSupportedIntegrations,
@@ -154,27 +154,19 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
definition, definition,
value: { value: {
advancedOptions: item.advancedOptions, advancedOptions: item.advancedOptions,
options, options: item.options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (editResult) => { onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
persistBoard((previous) => ({ persistBoard((previous) => ({
...previous, ...previous,
items: previous.items.map((boardItem) => items: previous.items.map((boardItem) =>
@@ -197,8 +189,6 @@ export const WidgetContextMenu = ({
), ),
integrationSupport: definition.supportedIntegrations !== undefined, integrationSupport: definition.supportedIntegrations !== undefined,
settings, settings,
itemId: item.id,
boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -207,16 +197,16 @@ export const WidgetContextMenu = ({
}, },
); );
}, [ }, [
board.id,
definition, definition,
integrationData, integrationData,
item, item,
handleRefetch, handleRefetch,
openModal, openModal,
options,
persistBoard, persistBoard,
settings, settings,
updateItemSettings, updateItemAdvancedOptions,
updateItemIntegrations,
updateItemOptions,
]); ]);
const handleToggle = useCallback( const handleToggle = useCallback(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

@@ -7,11 +7,9 @@
.inspectorContent { .inspectorContent {
--modal-content-flex: 0 0 100%; --modal-content-flex: 0 0 100%;
--modal-content-max-height: min(88dvh, 52rem); --modal-content-max-height: min(88dvh, 52rem);
--modal-size: 100%;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0;
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0; border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
} }
@@ -25,13 +23,8 @@
--modal-content-flex: 0 0 min(34rem, 46vw); --modal-content-flex: 0 0 min(34rem, 46vw);
--modal-content-height: 100dvh; --modal-content-height: 100dvh;
--modal-content-max-height: 100dvh; --modal-content-max-height: 100dvh;
--modal-size: min(34rem, 46vw);
width: min(34rem, 46vw); width: min(34rem, 46vw);
border-radius: 0; border-radius: 0;
} }
.inspectorInner:has([data-widget-inspector-side="left"]) {
justify-content: flex-start;
}
} }
-1
View File
@@ -19,7 +19,6 @@ export type CreateModalOptions = Pick<
| "centered" | "centered"
| "keepMounted" | "keepMounted"
| "withCloseButton" | "withCloseButton"
| "withOverlay"
| "zIndex" | "zIndex"
| "scrollAreaComponent" | "scrollAreaComponent"
| "yOffset" | "yOffset"
+1 -1
View File
@@ -2,7 +2,7 @@ import type { MantineProviderProps } from "@mantine/core";
import { theme } from "./theme"; import { theme } from "./theme";
export { theme } from "./theme"; export { scrollAreaDefaultProps, theme } from "./theme";
export { modalSizeForm, modalSizeSelect } from "./theme/modal"; export { modalSizeForm, modalSizeSelect } from "./theme/modal";
export * from "./components"; export * from "./components";
+13
View File
@@ -2,6 +2,13 @@ import { createTheme, rem } from "@mantine/core";
import { modalComponent } from "./theme/modal"; import { modalComponent } from "./theme/modal";
export const scrollAreaDefaultProps = {
type: "hover",
scrollbarSize: 2,
scrollHideDelay: 500,
offsetScrollbars: true,
} as const;
export const theme = createTheme({ export const theme = createTheme({
primaryColor: "red", primaryColor: "red",
autoContrast: true, autoContrast: true,
@@ -96,6 +103,12 @@ export const theme = createTheme({
style: { borderRadius: 5 }, style: { borderRadius: 5 },
}, },
}, },
ScrollArea: {
defaultProps: scrollAreaDefaultProps,
},
ScrollAreaAutosize: {
defaultProps: scrollAreaDefaultProps,
},
LoadingOverlay: { LoadingOverlay: {
defaultProps: { defaultProps: {
zIndex: 1000, zIndex: 1000,
@@ -324,7 +324,7 @@ export function JsonPathTreePicker({
</Combobox.Header> </Combobox.Header>
)} )}
<Combobox.Options> <Combobox.Options>
<ScrollArea.Autosize mah={420} type="scroll"> <ScrollArea.Autosize mah={420}>
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>} {!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>} {hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
{visibleNodes.map((node) => ( {visibleNodes.map((node) => (
@@ -177,7 +177,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
]; ];
return ( return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}> <ScrollArea h="100%" w="100%">
<Stack gap="md" p="md"> <Stack gap="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap"> <Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}> <Stack gap={2}>
@@ -163,7 +163,7 @@ export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, str
</Combobox.DropdownTarget> </Combobox.DropdownTarget>
<Combobox.Dropdown> <Combobox.Dropdown>
<ScrollArea.Autosize mah={280} type="auto"> <ScrollArea.Autosize mah={280}>
<Combobox.Options> <Combobox.Options>
{pendingUrl && !selectedValues.has(pendingUrl.id) ? ( {pendingUrl && !selectedValues.has(pendingUrl.id) ? (
<Combobox.Option value={createOptionValue}> <Combobox.Option value={createOptionValue}>
@@ -148,11 +148,7 @@ export default function BookmarksWidget({
</Center> </Center>
) : ( ) : (
<ScrollArea <ScrollArea
type="hover"
scrollbars={plan.horizontalScroll ? "x" : "y"} scrollbars={plan.horizontalScroll ? "x" : "y"}
scrollbarSize={2}
scrollHideDelay={500}
offsetScrollbars
style={{ flex: 1, minHeight: 0 }} style={{ flex: 1, minHeight: 0 }}
> >
<Box miw="100%" mih="100%" pb={2}> <Box miw="100%" mih="100%" pb={2}>
@@ -56,7 +56,6 @@ export const CalendarEventList = ({
return ( return (
<ScrollArea <ScrollArea
offsetScrollbars
pt={5} pt={5}
w="100%" w="100%"
h={fillHeight ? "100%" : undefined} h={fillHeight ? "100%" : undefined}
@@ -68,9 +68,6 @@ export const AdvancedClockView = ({
<ScrollArea <ScrollArea
h="100%" h="100%"
w="100%" w="100%"
type="auto"
offsetScrollbars
scrollbarSize={6}
aria-label={t("worldClock.advancedLabel")} aria-label={t("worldClock.advancedLabel")}
> >
<Stack gap="md" p={{ base: "sm", sm: "md" }}> <Stack gap="md" p={{ base: "sm", sm: "md" }}>
@@ -22,6 +22,16 @@
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
} }
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-8);
}
}
.homarr-data-table th { .homarr-data-table th {
padding: 0; padding: 0;
white-space: nowrap; white-space: nowrap;
@@ -6,6 +6,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable"; import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable"; import { DataTable } from "mantine-datatable";
import { scrollAreaDefaultProps } from "@homarr/ui";
const fallbackColumnWidth = 60; const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => { const getColumnWidth = (width: string | number | undefined): number => {
@@ -153,7 +155,7 @@ export function HomarrDataTable<T>({
resizable: !isEditMode, resizable: !isEditMode,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })), cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}} }}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }} scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
/> />
); );
} }
+1 -1
View File
@@ -184,7 +184,7 @@ export default function CountdownWidget({
onNotificationsChange={setNotifications} onNotificationsChange={setNotifications}
/> />
<ScrollArea type="auto" style={{ flex: 1, minHeight: 0 }}> <ScrollArea style={{ flex: 1, minHeight: 0 }}>
<Stack gap="md" pb="xs"> <Stack gap="md" pb="xs">
<EventSection <EventSection
title={t("upcoming")} title={t("upcoming")}
@@ -104,7 +104,7 @@ export default function DnsHoleSummaryWidget({
)} )}
</SimpleGrid> </SimpleGrid>
{showSourceStatuses && ( {showSourceStatuses && (
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs"> <ScrollArea px="xs" pb="xs">
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
{successfulSummaries.map(({ integration, summary }) => ( {successfulSummaries.map(({ integration, summary }) => (
<Badge <Badge
@@ -228,7 +228,7 @@ function Carousel({
)} )}
</Box> </Box>
{advanced && ( {advanced && (
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}> <ScrollArea px="xs" py={6}>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
{assets.map((asset, index) => ( {assets.map((asset, index) => (
<UnstyledButton <UnstyledButton
@@ -126,7 +126,6 @@ export default function MediaMissingWidget({
) => ( ) => (
<ScrollArea <ScrollArea
h="100%" h="100%"
scrollbarSize={4}
startScrollPosition={tabScrollPositionsRef.current[tab]} startScrollPosition={tabScrollPositionsRef.current[tab]}
onScrollPositionChange={(position) => { onScrollPositionChange={(position) => {
tabScrollPositionsRef.current[tab] = position; tabScrollPositionsRef.current[tab] = position;
@@ -61,7 +61,6 @@ export default function MediaServerWidget({
)} )}
<ScrollArea <ScrollArea
className="mediaRequests-list-scrollArea" className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }} style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
> >
<Stack className="mediaRequests-list-list" gap="xs" p="sm"> <Stack className="mediaRequests-list-list" gap="xs" p="sm">
@@ -17,10 +17,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
const t = useI18n("item.edit"); const t = useI18n("item.edit");
const theme = useMantineTheme(); const theme = useMantineTheme();
const form = useForm({ const form = useForm({
initialValues: { initialValues: innerProps.advancedOptions,
...innerProps.advancedOptions,
title: innerProps.advancedOptions.title ?? "",
},
}); });
const handleSubmit = (values: BoardItemAdvancedOptions) => { const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({ innerProps.onSuccess({
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import classes from "./widget-edit-modal.module.css";
interface FocusRect {
top: number;
left: number;
width: number;
height: number;
}
export type WidgetInspectorSide = "left" | "right";
const focusGap = 6;
const getFocusRect = (element: HTMLElement): FocusRect | null => {
const rect = element.getBoundingClientRect();
const top = Math.max(4, rect.top - focusGap);
const left = Math.max(4, rect.left - focusGap);
const right = Math.min(window.innerWidth - 4, rect.right + focusGap);
const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap);
if (right <= left || bottom <= top) return null;
return {
top,
left,
width: right - left,
height: bottom - top,
};
};
const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => {
if (current === next) return true;
if (!current || !next) return false;
return (
current.top === next.top &&
current.left === next.left &&
current.width === next.width &&
current.height === next.height
);
};
export const useWidgetEditFocus = (itemId: string | undefined, focusTargetId: string | undefined) => {
const [focusRect, setFocusRect] = useState<FocusRect | null>(null);
useIsomorphicEffect(() => {
let element: HTMLElement | null = null;
if (focusTargetId) {
element = document.getElementById(focusTargetId);
} else if (itemId) {
element = document.querySelector<HTMLElement>(`[data-advanced-focus-item-id="${CSS.escape(itemId)}"]`);
element ??= document.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
}
if (!element) {
setFocusRect(null);
return;
}
let animationFrame = 0;
const update = () => {
animationFrame = 0;
const nextRect = getFocusRect(element);
setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect));
};
const scheduleUpdate = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(update);
};
update();
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(element);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
window.cancelAnimationFrame(animationFrame);
};
}, [focusTargetId, itemId]);
const inspectorSide: WidgetInspectorSide =
focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right";
return { focusRect, inspectorSide };
};
export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => (
<Portal>
{focusRect ? (
<>
<Overlay fixed backgroundOpacity={0} zIndex={getDefaultZIndex("modal")} />
<Box
aria-hidden
className={classes.focusFrame}
style={{
top: focusRect.top,
left: focusRect.left,
width: focusRect.width,
height: focusRect.height,
zIndex: getDefaultZIndex("modal"),
}}
/>
</>
) : (
<Overlay fixed color="#000" backgroundOpacity={0.58} blur={1} zIndex={getDefaultZIndex("modal")} />
)}
</Portal>
);
@@ -1,90 +0,0 @@
.panel {
min-width: 0;
}
.settings {
min-width: 0;
container-type: inline-size;
}
.switchGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: var(--mantine-spacing-sm);
}
.switchOption {
display: flex;
height: 100%;
min-height: 4.25rem;
align-items: center;
padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.switchOption > *,
.switchOption :global(.mantine-Switch-root),
.switchOption :global(.mantine-Switch-body) {
width: 100%;
}
.switchOption :global(.mantine-Switch-body) {
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-sm);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.footer {
position: sticky;
z-index: 2;
bottom: 0;
margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent);
backdrop-filter: blur(10px);
}
.footerActions {
margin-inline-start: auto;
}
@container (min-width: 30rem) {
.switchGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
}
}
.focusFrame {
position: fixed;
pointer-events: none;
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent),
0 0 0 100vmax rgb(0 0 0 / 0.58);
clip-path: inset(-100vmax);
transition:
top 160ms ease,
left 160ms ease,
width 160ms ease,
height 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.focusFrame {
transition: none;
}
}
+189 -229
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react"; import type { ComponentType, FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
@@ -11,7 +11,7 @@ import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
@@ -26,10 +26,8 @@ import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select"; import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState { export interface WidgetEditModalState {
options: Record<string, unknown>; options: Record<string, unknown>;
@@ -52,14 +50,11 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
definition: WidgetDefinition; definition: WidgetDefinition;
value: WidgetEditModalState; value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void; onSuccessfulEdit: (value: WidgetEditModalState) => void;
onPreviewChange?: (value: WidgetEditModalState) => void;
onPreviewRestore?: () => void;
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
integrationSupport: boolean; integrationSupport: boolean;
settings: SettingsContextProps; settings: SettingsContextProps;
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
focusTargetId?: string;
appId?: string; appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>; integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
@@ -86,18 +81,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]); const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null); const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const hasPreviewedRef = useRef(false);
const hasSavedRef = useRef(false);
const previewCallbacksRef = useRef({
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
});
previewCallbacksRef.current = {
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
};
const widgetFormId = useId();
const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId, innerProps.focusTargetId);
z.config({ z.config({
customError: zodErrorMap(t), customError: zodErrorMap(t),
@@ -129,47 +112,25 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1); if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations); if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const widgetFormSchema = z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
});
const applyPreview = (value: WidgetEditModalState) => {
if (!innerProps.onPreviewChange) return;
hasPreviewedRef.current = true;
innerProps.onPreviewChange(value);
};
const form = useForm({ const form = useForm({
mode: "controlled", mode: "controlled",
initialValues: { initialValues: innerProps.value,
options: innerProps.value.options, validate: schemaResolver(
integrationIds: innerProps.value.integrationIds, z.object({
}, options: optionsSchema,
validate: schemaResolver(widgetFormSchema, { sync: true }), integrationIds: integrationIdsSchema,
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
{ sync: true },
),
validateInputOnBlur: true, validateInputOnBlur: true,
validateInputOnChange: true, validateInputOnChange: true,
onValuesChange: (values) => {
if (!widgetFormSchema.safeParse(values).success) return;
applyPreview({ ...values, advancedOptions });
},
}); });
const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
useEffect(
() => () => {
if (!hasPreviewedRef.current || hasSavedRef.current) return;
const { onPreviewChange, onPreviewRestore } = previewCallbacksRef.current;
if (onPreviewRestore) {
onPreviewRestore();
return;
}
onPreviewChange?.(innerProps.value);
},
[innerProps.value],
);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId; const appId = innerProps.appId;
@@ -249,7 +210,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
...values, ...values,
advancedOptions, advancedOptions,
}); });
hasSavedRef.current = true;
actions.closeModal(); actions.closeModal();
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -271,202 +231,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
} }
: undefined; : undefined;
const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type);
if (!Input) return false;
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
});
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
const widgetFormContent = ( const widgetFormContent = (
<form id={widgetFormId} onSubmit={onFormSubmit}> <Stack>
<Stack className={classes.settings} gap="md"> {canConfigureWidget && innerProps.integrationSupport && (
{canConfigureWidget && innerProps.integrationSupport && ( <WidgetIntegrationSelect
<WidgetIntegrationSelect label={tItem("field.integrations.label")}
label={tItem("field.integrations.label")} data={innerProps.integrationData}
data={innerProps.integrationData} canSelectMultiple={maxIntegrations > 1}
canSelectMultiple={maxIntegrations > 1} withAsterisk={integrationsRequired}
withAsterisk={integrationsRequired} onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} {...form.getInputProps("integrationIds")}
{...form.getInputProps("integrationIds")} />
/> )}
)} {canConfigureWidget &&
{canConfigureWidget && switchOptions.length > 0 && ( Object.entries(options).map(([key, value]) => {
<Box className={classes.switchGrid}> const Input = getInputForType(value.type);
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
return ( if (
<Box key={key} className={classes.switchOption}> !Input ||
<Input value.shouldHide?.(
kind={innerProps.kind} form.values.options as never,
property={key} innerProps.integrationData
options={value as never} .filter(({ id }) => form.values.integrationIds.includes(id))
initialOptions={innerProps.value.options} .map(({ kind }) => kind),
itemId={innerProps.itemId} )
boardId={innerProps.boardId ?? board?.id} ) {
/> return null;
</Box> }
);
})}
</Box>
)}
{canConfigureWidget &&
otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
return ( return (
<Input <Input
key={key} key={key}
kind={innerProps.kind} kind={innerProps.kind}
property={key} property={key}
options={value as never} options={value as never}
initialOptions={innerProps.value.options} initialOptions={innerProps.value.options}
itemId={innerProps.itemId} itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id} boardId={innerProps.boardId ?? board?.id}
/> />
); );
})} })}
</Stack> {showResourceTabs ? (
</form> <Button
); variant="subtle"
type="button"
return ( onClick={() =>
<> openModal({
<WidgetEditFocusOverlay focusRect={focusRect} /> advancedOptions,
<FormProvider form={form}> onSuccess: setAdvancedOptions,
<Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}> })
<Box pb="md"> }
{showResourceTabs ? ( >
<Tabs value={activeTab} onChange={setActiveTab}> {tItem("advancedOptions.label")}
<Tabs.List grow> </Button>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab> ) : (
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>} <ModalFormFooter
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>} onCancel={actions.closeModal}
</Tabs.List> leftSection={
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)}
</Box>
<Group className={classes.footer} justify="space-between" wrap="wrap">
<Button <Button
variant="subtle" variant="subtle"
type="button" type="button"
onClick={() => onClick={() =>
openModal({ openModal({
advancedOptions, advancedOptions,
onSuccess: (value) => { onSuccess: setAdvancedOptions,
setAdvancedOptions(value);
applyPreview({ ...form.getValues(), advancedOptions: value });
},
}) })
} }
> >
{tItem("advancedOptions.label")} {tItem("advancedOptions.label")}
</Button> </Button>
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap"> }
<Button type="button" onClick={actions.closeModal} variant="subtle" color="gray"> />
{tCommon("action.cancel")} )}
</Button> </Stack>
<Button type="submit" form={widgetFormId} loading={isSubmitting}> );
{tCommon("action.saveChanges")}
</Button> return (
</Group> <form onSubmit={onFormSubmit}>
</Group> <FormProvider form={form}>
</Stack> {showResourceTabs ? (
<Stack>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Box
pos="sticky"
bottom={0}
style={{
marginInline: "calc(var(--mantine-spacing-md) * -1)",
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-sm)",
background: "var(--mantine-color-body)",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Box>
</Stack>
) : (
widgetFormContent
)}
</FormProvider> </FormProvider>
</> </form>
); );
}).withOptions({ }).withOptions({
keepMounted: true, keepMounted: true,
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector",
withOverlay: false,
size: modalSizeForm, size: modalSizeForm,
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -500,7 +500,6 @@ export function Notebook({
<ScrollArea <ScrollArea
mih="4rem" mih="4rem"
offsetScrollbars
pl={12} pl={12}
pt={12} pt={12}
styles={{ styles={{
+1 -1
View File
@@ -387,7 +387,7 @@ const AdvancedTimer = ({
const t = useI18n("widget.timer"); const t = useI18n("widget.timer");
const phaseLabel = getTimerPhaseLabel(runtime, t); const phaseLabel = getTimerPhaseLabel(runtime, t);
return ( return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}> <ScrollArea h="100%" w="100%">
<Stack gap="md" p="md"> <Stack gap="md" p="md">
<Paper <Paper
withBorder withBorder
@@ -5,7 +5,6 @@
} }
.chartSurface :global(.mantine-ScrollArea-viewport), .chartSurface :global(.mantine-ScrollArea-viewport),
.chartSurface :global(.mantine-ScrollArea-scrollbar),
.chartSurface :global(.recharts-wrapper), .chartSurface :global(.recharts-wrapper),
.chartSurface :global(.recharts-surface) { .chartSurface :global(.recharts-surface) {
background-color: transparent !important; background-color: transparent !important;
+1 -1
View File
@@ -46,7 +46,7 @@ export default function VpnWidget({ options, integrationIds, width, height }: Wi
} }
return ( return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" offsetScrollbars p="xs"> <ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<SimpleGrid cols={width >= 640 ? 2 : 1} spacing="sm"> <SimpleGrid cols={width >= 640 ? 2 : 1} spacing="sm">
{integrations.map((result) => ( {integrations.map((result) => (
<Stack key={result.integration.id} gap={4}> <Stack key={result.integration.id} gap={4}>
+1 -1
View File
@@ -143,7 +143,7 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt); const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt);
return ( return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}> <ScrollArea h="100%" w="100%">
<Stack className={classes.advancedRoot} gap="md" p="md"> <Stack className={classes.advancedRoot} gap="md" p="md">
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap"> <Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">