Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa667b8cc | ||
|
|
d3192688c6 | ||
|
|
11626eda8a | ||
|
|
f58c0ac77c | ||
|
|
b3e24f0d34 | ||
|
|
7d742c9efa | ||
|
|
1b843817a4 | ||
|
|
8ae9630b3e | ||
|
|
63f5c43b69 | ||
|
|
e97fe7861d |
No files matched your search
@@ -194,11 +194,15 @@ 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>
|
||||
</figure>
|
||||
|
||||
In the modal you can edit the app that you want to display:
|
||||
The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
|
||||
|
||||
<figure>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
<img
|
||||
src={editItemCurrent}
|
||||
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>
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 144 KiB |
@@ -27,9 +27,10 @@ opening it. Containers include their visible widgets and app icons instead of ap
|
||||
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows
|
||||
structural previews of the other boards you can access. Start typing to filter by name, use the arrow keys to move
|
||||
through the results, and press `Enter` to open the selected board.
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
||||
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
||||
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
|
||||
arrow keys to move through the results and press `Enter` to open the selected board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { use, useCallback, useId, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
@@ -32,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const previewId = useId();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
@@ -58,6 +59,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
const originalState = state;
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
@@ -66,11 +68,14 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
onPreviewChange: setState,
|
||||
onPreviewRestore: () => setState(originalState),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
focusTargetId: previewId,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
@@ -86,7 +91,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -110,7 +115,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.canvas {
|
||||
min-height: 8rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
aspect-ratio: 16 / 7;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
@@ -83,9 +84,3 @@
|
||||
gap: rem(1px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
.canvas {
|
||||
min-height: 7rem;
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,12 @@ import classes from "./board-layout-thumbnail.module.css";
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
@@ -26,6 +27,7 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -65,12 +67,12 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
|
||||
element.xOffset >= 0 &&
|
||||
element.xOffset < columnCount &&
|
||||
element.yOffset >= 0 &&
|
||||
element.yOffset < maxThumbnailRows &&
|
||||
element.yOffset < thumbnailRowLimit &&
|
||||
element.width > 0 &&
|
||||
element.height > 0,
|
||||
);
|
||||
const rowCount = Math.min(
|
||||
maxThumbnailRows,
|
||||
thumbnailRowLimit,
|
||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
.modalContent {
|
||||
overflow: hidden;
|
||||
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
|
||||
border-radius: rem(24px);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
|
||||
box-shadow: 0 rem(18px) rem(56px) rgb(0 0 0 / 22%);
|
||||
backdrop-filter: blur(rem(20px)) saturate(140%);
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.content {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
|
||||
backdrop-filter: blur(rem(8px));
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
@@ -11,6 +30,12 @@
|
||||
.card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.metadataBadge {
|
||||
width: rem(22px);
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.option:hover .card,
|
||||
|
||||
@@ -3,29 +3,31 @@
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Kbd,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMatches,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } from "@tabler/icons-react";
|
||||
import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
|
||||
@@ -39,6 +41,7 @@ interface BoardSwitcherProps {
|
||||
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
const t = useI18n("board.action.switcher");
|
||||
const manageBoardsT = useI18n("management.page.board");
|
||||
const currentBoard = useOptionalBoard();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -50,20 +53,27 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const availableBoards = useMemo(
|
||||
() => boards.filter((board) => board.id !== currentBoard?.id),
|
||||
const switcherBoards = useMemo(
|
||||
() => [
|
||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||
...boards.filter((board) => board.id === currentBoard?.id),
|
||||
],
|
||||
[boards, currentBoard?.id],
|
||||
);
|
||||
const filteredBoards = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
if (normalizedSearch.length === 0) return availableBoards;
|
||||
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||
}, [availableBoards, search]);
|
||||
if (normalizedSearch.length === 0) return switcherBoards;
|
||||
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||
}, [search, switcherBoards]);
|
||||
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
|
||||
|
||||
const openSwitcher = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
@@ -73,7 +83,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
|
||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||
|
||||
@@ -88,14 +98,17 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
}, [activeIndex, filteredBoards.length, isOpen]);
|
||||
|
||||
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Backspace") {
|
||||
const isSearchInput =
|
||||
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
|
||||
|
||||
if (!isSearchInput && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
setSearch((current) => current.slice(0, -1));
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
setSearch((current) => `${current}${event.key}`);
|
||||
setActiveIndex(0);
|
||||
@@ -141,7 +154,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
};
|
||||
|
||||
const results = getBoardSwitcherResults({
|
||||
availableBoardCount: availableBoards.length,
|
||||
availableBoardCount: switcherBoards.length,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
@@ -152,6 +165,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
manageBoardsT,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -161,54 +175,40 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={closeSwitcher}
|
||||
title={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="md">
|
||||
<IconReplace size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>{t("title")}</Text>
|
||||
</Group>
|
||||
}
|
||||
size={1280}
|
||||
yOffset={32}
|
||||
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
|
||||
withCloseButton={false}
|
||||
yOffset="15vh"
|
||||
aria-label={t("title")}
|
||||
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
|
||||
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
|
||||
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||
classNames={{ content: classes.modalContent, body: classes.modalBody }}
|
||||
>
|
||||
<Stack
|
||||
data-autofocus
|
||||
tabIndex={-1}
|
||||
gap="md"
|
||||
gap="xs"
|
||||
onKeyDown={handleModalKeyDown}
|
||||
className={classes.content}
|
||||
aria-describedby="board-switcher-instructions"
|
||||
>
|
||||
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
data-board-switcher-search
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
leftSection={<IconSearch size="1rem" stroke={1.6} />}
|
||||
placeholder={t("title")}
|
||||
aria-label={t("title")}
|
||||
aria-controls="board-switcher-results"
|
||||
radius="xl"
|
||||
classNames={{ input: classes.searchInput }}
|
||||
/>
|
||||
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
|
||||
{results}
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
|
||||
{currentBoard && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("currentHidden", { name: currentBoard.name })}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<Kbd size="xs">
|
||||
<IconArrowRight size={12} />
|
||||
</Kbd>
|
||||
<Kbd size="xs">
|
||||
<IconArrowDown size={12} />
|
||||
</Kbd>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("keyboard.navigate")}
|
||||
</Text>
|
||||
<Kbd size="xs">Enter</Kbd>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("keyboard.open")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
@@ -227,6 +227,7 @@ interface BoardSwitcherResultsProps {
|
||||
setActiveIndex: (index: number) => void;
|
||||
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||
columnCount: number;
|
||||
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
|
||||
}
|
||||
|
||||
const getBoardSwitcherResults = ({
|
||||
@@ -241,6 +242,7 @@ const getBoardSwitcherResults = ({
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
manageBoardsT,
|
||||
}: BoardSwitcherResultsProps) => {
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -287,7 +289,7 @@ const getBoardSwitcherResults = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
|
||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
|
||||
{filteredBoards.map((board, index) => (
|
||||
<UnstyledButton
|
||||
key={board.id}
|
||||
@@ -296,7 +298,7 @@ const getBoardSwitcherResults = ({
|
||||
}}
|
||||
className={classes.option}
|
||||
w="100%"
|
||||
maw={320}
|
||||
maw={240}
|
||||
mx="auto"
|
||||
component={Link}
|
||||
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||
@@ -306,21 +308,50 @@ const getBoardSwitcherResults = ({
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onPointerEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<Card padding={0} withBorder radius="md" className={classes.card}>
|
||||
<Card padding={0} withBorder radius="lg" className={classes.card}>
|
||||
<Card.Section>
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
fitFullLayout
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section withBorder p="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="md" size="md">
|
||||
<IconLayoutBoard size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={650} truncate>
|
||||
<Card.Section withBorder px="sm" py={6}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{board.isHome && (
|
||||
<Tooltip label={manageBoardsT("action.setHomeBoard.badge.tooltip")}>
|
||||
<Badge
|
||||
className={classes.metadataBadge}
|
||||
color="yellow"
|
||||
variant="light"
|
||||
aria-label={manageBoardsT("action.setHomeBoard.badge.label")}
|
||||
>
|
||||
<IconHomeFilled size="0.7rem" />
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={manageBoardsT("action.setMobileHomeBoard.badge.tooltip")}>
|
||||
<Badge
|
||||
className={classes.metadataBadge}
|
||||
color="yellow"
|
||||
variant="light"
|
||||
aria-label={manageBoardsT("action.setMobileHomeBoard.badge.label")}
|
||||
>
|
||||
<IconDeviceMobile size="0.7rem" />
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{board.creator && (
|
||||
<Tooltip label={board.creator.name ?? manageBoardsT("preview.unknownCreator")}>
|
||||
<UserAvatar user={board.creator} size="xs" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
@@ -30,6 +30,13 @@ interface UpdateItemIntegrations {
|
||||
newIntegrations: string[];
|
||||
}
|
||||
|
||||
interface UpdateItemSettings {
|
||||
itemId: string;
|
||||
options: Record<string, unknown>;
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
integrationIds: string[];
|
||||
}
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -77,6 +84,20 @@ export const useItemActions = () => {
|
||||
[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(
|
||||
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
||||
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
||||
@@ -105,6 +126,7 @@ export const useItemActions = () => {
|
||||
updateItemOptions,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemSettings,
|
||||
duplicateItem,
|
||||
createItem,
|
||||
};
|
||||
|
||||
@@ -297,6 +297,7 @@ const LoadedBoardItemContent = ({
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getWidgetName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -33,6 +34,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
};
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useI18n("item");
|
||||
@@ -40,8 +42,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
|
||||
const { integrations: integrationData, section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
|
||||
@@ -55,6 +56,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
const openEditModal = () => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
@@ -64,19 +70,14 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrationIds,
|
||||
});
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
onSuccessfulEdit: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
onIntegrationSaved: resetErrorBoundary,
|
||||
@@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -277,6 +277,7 @@ const ItemSelectModalContent = ({
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
@@ -346,6 +347,7 @@ const ItemSelectModalContent = ({
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||
|
||||
@@ -157,6 +157,8 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
|
||||
const tCommon = useI18n("common.action");
|
||||
const settings = useSettings();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { updateItemSettings } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
@@ -154,19 +154,27 @@ export const WidgetContextMenu = ({
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
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) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
@@ -189,6 +197,8 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -197,16 +207,16 @@ export const WidgetContextMenu = ({
|
||||
},
|
||||
);
|
||||
}, [
|
||||
board.id,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
options,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemOptions,
|
||||
updateItemSettings,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
|
||||
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 651 KiB |
@@ -86,6 +86,7 @@ interface BoardItemPlacementRectangle {
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -225,7 +226,7 @@ export const boardRouter = createTRPCRouter({
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
||||
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
@@ -306,7 +307,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
@@ -331,7 +332,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
@@ -366,7 +367,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
|
||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||
});
|
||||
|
||||
test("returns one compact base-layout preview for each accessible board", async () => {
|
||||
test("returns compact previews by default and full previews on request", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const boardId = createId();
|
||||
@@ -487,6 +487,10 @@ describe("getManageOverview", () => {
|
||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
||||
expect(result[0]?.preview?.sections).toHaveLength(2);
|
||||
|
||||
const fullResult = await caller.getManageOverview({ fullPreview: true });
|
||||
|
||||
expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]);
|
||||
});
|
||||
|
||||
test("includes app and widget icons in compact previews", async () => {
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
.inspectorContent {
|
||||
--modal-content-flex: 0 0 100%;
|
||||
--modal-content-max-height: min(88dvh, 52rem);
|
||||
--modal-size: 100%;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||
}
|
||||
|
||||
@@ -23,8 +25,13 @@
|
||||
--modal-content-flex: 0 0 min(34rem, 46vw);
|
||||
--modal-content-height: 100dvh;
|
||||
--modal-content-max-height: 100dvh;
|
||||
--modal-size: min(34rem, 46vw);
|
||||
|
||||
width: min(34rem, 46vw);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.inspectorInner:has([data-widget-inspector-side="left"]) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
|
||||
| "centered"
|
||||
| "keepMounted"
|
||||
| "withCloseButton"
|
||||
| "withOverlay"
|
||||
| "zIndex"
|
||||
| "scrollAreaComponent"
|
||||
| "yOffset"
|
||||
|
||||
@@ -17,7 +17,10 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
const t = useI18n("item.edit");
|
||||
const theme = useMantineTheme();
|
||||
const form = useForm({
|
||||
initialValues: innerProps.advancedOptions,
|
||||
initialValues: {
|
||||
...innerProps.advancedOptions,
|
||||
title: innerProps.advancedOptions.title ?? "",
|
||||
},
|
||||
});
|
||||
const handleSubmit = (values: BoardItemAdvancedOptions) => {
|
||||
innerProps.onSuccess({
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"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>
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ComponentType, FormEvent } from "react";
|
||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
@@ -11,7 +11,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
@@ -26,8 +26,10 @@ import { OPTIONS_SUPER_REFINE } from "../options";
|
||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
|
||||
import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
|
||||
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
|
||||
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
|
||||
import classes from "./widget-edit-modal.module.css";
|
||||
|
||||
export interface WidgetEditModalState {
|
||||
options: Record<string, unknown>;
|
||||
@@ -50,11 +52,14 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
definition: WidgetDefinition;
|
||||
value: WidgetEditModalState;
|
||||
onSuccessfulEdit: (value: WidgetEditModalState) => void;
|
||||
onPreviewChange?: (value: WidgetEditModalState) => void;
|
||||
onPreviewRestore?: () => void;
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
focusTargetId?: string;
|
||||
appId?: string;
|
||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
||||
onIntegrationSaved?: () => void;
|
||||
@@ -81,6 +86,18 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
|
||||
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
|
||||
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({
|
||||
customError: zodErrorMap(t),
|
||||
@@ -112,25 +129,47 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
|
||||
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({
|
||||
mode: "controlled",
|
||||
initialValues: innerProps.value,
|
||||
validate: schemaResolver(
|
||||
z.object({
|
||||
options: optionsSchema,
|
||||
integrationIds: integrationIdsSchema,
|
||||
advancedOptions: z.object({
|
||||
customCssClasses: z.array(z.string()),
|
||||
borderColor: z.string(),
|
||||
}),
|
||||
}),
|
||||
{ sync: true },
|
||||
),
|
||||
initialValues: {
|
||||
options: innerProps.value.options,
|
||||
integrationIds: innerProps.value.integrationIds,
|
||||
},
|
||||
validate: schemaResolver(widgetFormSchema, { sync: true }),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
onValuesChange: (values) => {
|
||||
if (!widgetFormSchema.safeParse(values).success) return;
|
||||
|
||||
applyPreview({ ...values, advancedOptions });
|
||||
},
|
||||
});
|
||||
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 canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const appId = innerProps.appId;
|
||||
@@ -210,6 +249,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
hasSavedRef.current = true;
|
||||
actions.closeModal();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -231,202 +271,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
}
|
||||
: 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 = (
|
||||
<Stack>
|
||||
{canConfigureWidget && innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={tItem("field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{canConfigureWidget &&
|
||||
Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
<form id={widgetFormId} onSubmit={onFormSubmit}>
|
||||
<Stack className={classes.settings} gap="md">
|
||||
{canConfigureWidget && innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={tItem("field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{canConfigureWidget && switchOptions.length > 0 && (
|
||||
<Box className={classes.switchGrid}>
|
||||
{switchOptions.map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) return null;
|
||||
|
||||
if (
|
||||
!Input ||
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box key={key} className={classes.switchOption}>
|
||||
<Input
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{canConfigureWidget &&
|
||||
otherOptions.map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) return null;
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showResourceTabs ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess: setAdvancedOptions,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tItem("advancedOptions.label")}
|
||||
</Button>
|
||||
) : (
|
||||
<ModalFormFooter
|
||||
onCancel={actions.closeModal}
|
||||
leftSection={
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetEditFocusOverlay focusRect={focusRect} />
|
||||
<FormProvider form={form}>
|
||||
<Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}>
|
||||
<Box pb="md">
|
||||
{showResourceTabs ? (
|
||||
<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>
|
||||
) : (
|
||||
widgetFormContent
|
||||
)}
|
||||
</Box>
|
||||
<Group className={classes.footer} justify="space-between" wrap="wrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess: setAdvancedOptions,
|
||||
onSuccess: (value) => {
|
||||
setAdvancedOptions(value);
|
||||
applyPreview({ ...form.getValues(), advancedOptions: value });
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{tItem("advancedOptions.label")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<FormProvider form={form}>
|
||||
{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
|
||||
)}
|
||||
<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>
|
||||
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}).withOptions({
|
||||
keepMounted: true,
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||