Compare commits

..
Author SHA1 Message Date
ajnart 0aa667b8cc feat: preview widget settings live 2026-08-22 21:42:41 +02:00
ajnart d3192688c6 docs: add high resolution widget editor captures 2026-08-22 21:38:03 +02:00
ajnart 11626eda8a docs: include restored board in demo gif 2026-08-22 21:32:11 +02:00
ajnart f58c0ac77c docs: add widget settings demo media 2026-08-22 21:32:00 +02:00
ajnart b3e24f0d34 fix: harden widget settings inspector 2026-08-22 20:27:06 +02:00
ajnart 7d742c9efa feat: focus widget settings beside board item 2026-08-22 20:06:55 +02:00
Thomas 1b843817a4 feat: polish board switcher (#6670) 2026-08-22 17:21:10 +02:00
Thomas Camlong 8ae9630b3e Revert "fix: position calendar error indicator top-left (#6668)"
This reverts commit 63f5c43b69.
2026-08-22 17:03:15 +02:00
Thomas 63f5c43b69 fix: position calendar error indicator top-left (#6668) 2026-08-22 16:57:34 +02:00
Thomas e97fe7861d feat: rework bookmarks widget (#6666) 2026-08-22 16:55:31 +02:00
Thomas 19760e5722 fix: restore translucent dashboard styling (#6664)
* fix: restore translucent dashboard styling

* fix: restore dashboard widget transparency

* fix: address dashboard review feedback

* fix: apply board opacity to containers

* fix: flatten demo board container

* fix: default board roundness to sm

* fix: restore assistant widget transparency

* Revert "fix: default board roundness to sm"

This reverts commit c235cbdf2b.

* fix: address remaining dashboard review comments

* test: align grid geometry expectations
2026-08-22 15:51:43 +02:00
Thomas 2570de25a6 fix: hide default weather details (#6665) 2026-08-22 15:48:02 +02:00
57 changed files with 1764 additions and 1022 deletions

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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 144 KiB

+4 -3
View File
@@ -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
+7 -6
View File
@@ -1,6 +1,6 @@
---
title: "Bookmarks"
description: "Displays multiple app links"
description: "Keeps useful links together"
hide_title: true
---
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget allows you to display multiple links in a single widget.
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
For links that do not need an App, add their URLs under **Custom links** instead.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
### Screenshots
+18 -12
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark,
name: "Bookmarks",
description: "Displays multiple app links",
description: "Keeps useful links together",
path: "../../widgets/bookmarks",
configuration: {
items: [
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Layout",
description: "Layout in which the bookmarks are displayed.",
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
defaultValue: "Vertical",
description: "How bookmarks use the available widget space.",
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
defaultValue: "Adaptive",
},
{
name: "Appearance",
description: "Card surface style.",
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
defaultValue: "Soft",
},
{
name: "Card spacing",
description: "Space between bookmark cards.",
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
defaultValue: "Extra small",
},
{
name: "Hide title",
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Bookmarks",
description: "List of bookmarks to display in the widget.",
values: "Select one or more Apps directly, then order them as you like.",
description: "URLs and existing Apps shown by the widget.",
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
defaultValue: "No bookmarks selected",
},
{
name: "Custom links",
description: "URLs to display without first creating an App.",
values: "List of URLs.",
defaultValue: "No custom links",
},
],
},
};
@@ -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(
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

+5 -4
View File
@@ -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),
),
+5 -1
View File
@@ -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 () => {
@@ -161,7 +161,7 @@ export interface DefaultWidgetConfig {
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
{ kind: "clock", width: 2, height: 1 },
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
];
@@ -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;
}
}
+1
View File
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
| "centered"
| "keepMounted"
| "withCloseButton"
| "withOverlay"
| "zIndex"
| "scrollAreaComponent"
| "yOffset"
+55 -10
View File
@@ -2192,8 +2192,51 @@
"layout": {
"label": "Layout",
"option": {
"adaptive": {
"label": "Adaptive"
},
"gridHorizontal": {
"label": "Grid horizontal"
"label": "Compact grid"
},
"icons": {
"label": "Icons"
}
}
},
"variant": {
"label": "Appearance",
"option": {
"soft": {
"label": "Soft"
},
"filled": {
"label": "Filled"
},
"outline": {
"label": "Outline"
},
"plain": {
"label": "Plain"
}
}
},
"spacing": {
"label": "Card spacing",
"option": {
"xs": {
"label": "Extra small"
},
"sm": {
"label": "Small"
},
"md": {
"label": "Medium"
},
"lg": {
"label": "Large"
},
"xl": {
"label": "Extra large"
}
}
},
@@ -2210,17 +2253,21 @@
"label": "Show border"
},
"customUrls": {
"label": "Custom links",
"description": "Add URLs directly without creating an App first"
"label": "Custom links"
},
"items": {
"label": "Bookmarks",
"remove": "Remove bookmark",
"apps": {
"label": "Apps",
"placeholder": "Select one or more apps"
"label": "Add bookmarks",
"description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.",
"placeholder": "Paste URLs or search Apps",
"addUrl": "Add this URL",
"hint": "Press Enter to add a URL. Paste comma-separated or one-per-line URLs to add several."
}
}
}
},
"empty": "Add a URL or App in the widget settings"
},
"archiveTeamWarrior": {
"name": "ArchiveTeam Warrior",
@@ -3012,12 +3059,10 @@
"label": "Animate weather icons"
},
"showHumidity": {
"label": "Show humidity",
"description": "Display humidity in current conditions, hourly details and daily forecasts"
"label": "Show humidity"
},
"showCurrentWindSpeed": {
"label": "Show current wind speed",
"description": "Only on current weather"
"label": "Show current wind speed"
},
"useImperialSpeed": {
"label": "Use mph for windspeed"
+1
View File
@@ -25,6 +25,7 @@ export const theme = createTheme({
},
fontSizes: {
xxs: rem(11),
xs: rem(12),
sm: rem(14),
md: rem(16),
+1 -1
View File
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
/**
* Short description why as and unknown convertions are used below:
@@ -96,6 +96,28 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
};
const migrateItems = useCallback(
(items: TItem[], optionsPatch: Record<string, unknown>) => {
const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const);
setTempMap((previous) => {
const next = new Map(previous);
for (const [value, item] of migratedEntries) next.set(value, item);
return next;
});
form.setFieldValue("options", (currentOptions) => {
const currentItems = Array.isArray(currentOptions[property])
? (currentOptions[property] as TOptionValue[])
: options.defaultValue;
const nextItems = [...currentItems];
for (const [value] of migratedEntries) {
if (!nextItems.includes(value)) nextItems.push(value);
}
return { ...currentOptions, ...optionsPatch, [property]: nextItems };
});
},
[form, options, property],
);
const removeItem = useCallback(
(value: TOptionValue) => {
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
@@ -112,7 +134,13 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
<options.addButton
addItem={addItem}
migrateItems={migrateItems}
removeItem={removeItem}
values={values}
initialOptions={initialOptions}
/>
<DndContext
sensors={sensors}
@@ -153,6 +181,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
index={index}
item={item}
removeItem={() => removeItem(value)}
removeLabel={t("remove")}
options={options}
/>
);
@@ -177,6 +206,7 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
item: TItem;
index: number;
removeItem: () => void;
removeLabel: string;
options: CommonWidgetInputProps<"sortableItemList">["options"];
}
@@ -185,6 +215,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
index,
item,
removeItem,
removeLabel,
options,
}: ItemProps<TItem, TOptionValue>) => {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
@@ -220,6 +251,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
key={index}
item={item}
removeItem={removeItem}
removeLabel={removeLabel}
rootAttributes={attributes}
handle={
<ActionIcon
+172 -53
View File
@@ -1,79 +1,198 @@
"use client";
import type { MultiSelectProps } from "@mantine/core";
import { Group, Loader, MultiSelect } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Avatar,
Combobox,
Group,
Loader,
PillsInput,
ScrollArea,
Stack,
Text,
ThemeIcon,
useCombobox,
} from "@mantine/core";
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
type SelectableApp = RouterOutputs["app"]["selectable"][number];
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
const createOptionValue = "$create-url";
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
addItem,
removeItem,
migrateItems,
values,
initialOptions,
}) => {
const t = useI18n("widget.bookmarks.option.items.apps");
const tCommon = useI18n("common");
const tSelect = useI18n("app.action.select");
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
const appsById = new Map(apps.map((app) => [app.id, app]));
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const selectedValues = useMemo(() => new Set(values), [values]);
const migrationStartedRef = useRef(false);
const legacyUrls = useMemo(() => {
if (!Array.isArray(initialOptions.customUrls)) return [];
return initialOptions.customUrls.filter((value): value is string => typeof value === "string");
}, [initialOptions.customUrls]);
const handleChange = (nextValues: string[]) => {
const nextIds = new Set(nextValues);
for (const value of values) {
if (!nextIds.has(value)) removeItem(value);
}
useEffect(() => {
if (migrationStartedRef.current || legacyUrls.length === 0) return;
const currentIds = new Set(values);
for (const value of nextValues) {
if (currentIds.has(value)) continue;
const app = appsById.get(value);
if (app) addItem(app);
migrationStartedRef.current = true;
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
migrateItems(legacyItems, { customUrls: [] });
}, [legacyUrls, migrateItems]);
const normalizedSearch = search.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
if (selectedValues.has(app.id)) return false;
if (normalizedSearch.length === 0) return true;
return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch);
});
const pendingUrl = createDirectBookmark(search);
const addUrls = (input: string) => {
const nextValues = new Set(values);
for (const value of splitBookmarkUrls(input)) {
const item = createDirectBookmark(value);
if (!item || nextValues.has(item.id)) continue;
addItem(item);
nextValues.add(item.id);
}
setSearch("");
combobox.closeDropdown();
};
const handleOptionSubmit = (value: string) => {
if (value === createOptionValue) {
addUrls(search);
return;
}
const app = apps.find((candidate) => candidate.id === value);
if (app) addItem(app);
setSearch("");
};
const options = filteredApps.map((app) => (
<Combobox.Option value={app.id} key={app.id}>
<Group gap="sm" wrap="nowrap">
<Avatar src={app.iconUrl} name={app.name} color="gray" size={28} radius="sm">
<IconLink size={16} />
</Avatar>
<Stack gap={0} miw={0} flex={1}>
<Text size="sm" fw={600} truncate>
{app.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{app.href}
</Text>
</Stack>
<IconPlus size={16} aria-hidden />
</Group>
</Combobox.Option>
));
return (
<MultiSelect
label={t("label")}
placeholder={t("placeholder")}
value={values}
onChange={handleChange}
searchable
clearable
clearSearchOnChange
hidePickedOptions
disabled={isPending || Boolean(error)}
error={error ? tCommon("error") : undefined}
rightSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tSelect("notFound")}
renderOption={renderAppOption}
data={apps.map((app) => ({
value: app.id,
label: app.name,
iconUrl: app.iconUrl,
}))}
/>
<Combobox
store={combobox}
onOptionSubmit={handleOptionSubmit}
position="bottom-start"
middlewares={{ flip: true, shift: true }}
styles={{
dropdown: { overflow: "hidden" },
option: { borderRadius: "var(--mantine-radius-sm)" },
}}
>
<Combobox.DropdownTarget>
<PillsInput
label={t("label")}
description={t("description")}
error={error ? tCommon("error") : undefined}
onClick={() => combobox.openDropdown()}
leftSection={
<ThemeIcon variant="light" size="sm" radius="xl">
<IconLink size={14} />
</ThemeIcon>
}
rightSection={isPending ? <Loader size="xs" /> : undefined}
styles={{ input: { minHeight: 44 } }}
>
<Combobox.EventsTarget>
<PillsInput.Field
value={search}
placeholder={t("placeholder")}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onPaste={(event) => {
const pastedValue = event.clipboardData.getData("text");
if (splitBookmarkUrls(pastedValue).length < 2) return;
event.preventDefault();
addUrls(pastedValue);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (!pendingUrl) return;
event.preventDefault();
addUrls(search);
}}
/>
</Combobox.EventsTarget>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<ScrollArea.Autosize mah={280} type="auto">
<Combobox.Options>
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
<Combobox.Option value={createOptionValue}>
<Group gap="sm" wrap="nowrap">
<Avatar src={getBookmarkFaviconUrl(pendingUrl.href)} size={28} radius="sm" color="gray">
<IconLink size={16} />
</Avatar>
<Stack gap={0} miw={0} flex={1}>
<Text size="sm" fw={600} truncate>
{t("addUrl")}
</Text>
<Text size="xs" c="dimmed" truncate>
{pendingUrl.href}
</Text>
</Stack>
<IconCheck size={16} aria-hidden />
</Group>
</Combobox.Option>
) : null}
{options}
{!pendingUrl && options.length === 0 ? <Combobox.Empty>{tSelect("notFound")}</Combobox.Empty> : null}
</Combobox.Options>
</ScrollArea.Autosize>
<Combobox.Footer>
<Text size="xs" c="dimmed">
{t("hint")}
</Text>
</Combobox.Footer>
</Combobox.Dropdown>
</Combobox>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt="" />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
@@ -0,0 +1,66 @@
import { getSafeApplicationUrl } from "../common/application-url";
export const directBookmarkPrefix = "url:";
export interface BookmarkItem {
id: string;
name: string;
description: string | null;
iconUrl?: string;
href: string | null;
}
export const normalizeBookmarkUrl = (value: string): string | undefined => {
const trimmedValue = value.trim();
if (trimmedValue.length === 0) return undefined;
const directUrl = getSafeApplicationUrl(trimmedValue);
if (directUrl) return directUrl;
if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) {
return getSafeApplicationUrl(`https://${trimmedValue}`);
}
return undefined;
};
export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`;
export const getDirectBookmarkUrl = (value: string): string | undefined => {
if (!value.startsWith(directBookmarkPrefix)) return undefined;
return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length));
};
export const getBookmarkFaviconUrl = (href: string | null): string | undefined => {
const safeUrl = getSafeApplicationUrl(href);
if (!safeUrl) return undefined;
const url = new URL(safeUrl);
return `${url.origin}/favicon.ico`;
};
export const createDirectBookmark = (url: string): BookmarkItem | undefined => {
const href = normalizeBookmarkUrl(url);
if (!href) return undefined;
const parsedUrl = new URL(href);
return {
id: getDirectBookmarkValue(href),
name: parsedUrl.hostname,
description: null,
href,
};
};
export const splitBookmarkUrls = (value: string) =>
value.split(/\r?\n+/).flatMap((line) => {
const trimmedLine = line.trim();
if (trimmedLine.length === 0) return [];
const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim());
if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) {
return commaSeparatedValues;
}
return [trimmedLine];
});
@@ -1,45 +0,0 @@
.card {
background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
transition:
background-color 120ms ease,
border-color 120ms ease;
}
.card:hover,
.card:focus-within {
background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
}
.bookmarkButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
.bookmarkButton:focus-visible .bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
.compactScrollViewport > div {
display: flex !important;
width: 100%;
min-height: 100%;
}
.compactScrollContent {
flex: 1;
min-width: 100%;
min-height: 100%;
}
@@ -0,0 +1,368 @@
"use client";
import { useMemo, useState } from "react";
import {
Avatar,
Box,
Card,
Center,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import { IconArrowUpRight, IconBookmark, IconLink } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
import type { BookmarkOrientation } from "./layout";
type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"];
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const t = useI18n("widget.bookmarks");
const board = useRequiredBoard();
const advanced = displayMode === "advanced";
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(appIds, {
select(selectedApps) {
return selectedApps;
},
}),
) ?? [];
const appsById = new Map(apps.map((app) => [app.id, app]));
const configuredItems = options.items.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
if (directUrl) return createDirectBookmark(directUrl) ?? [];
return appsById.get(value) ?? [];
});
const configuredHrefs = new Set(configuredItems.map((item) => item.href));
const legacyItems = options.customUrls.flatMap((url) => {
const item = createDirectBookmark(url);
if (!item || configuredHrefs.has(item.href)) return [];
return [item];
});
const data = [...configuredItems, ...legacyItems];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data.flatMap((bookmark) => {
const href = getSafeAppHref(bookmark.href);
if (!href) return [];
return [
{
id: bookmark.id,
name: bookmark.name,
icon: bookmark.iconUrl ?? IconLink,
interaction() {
return {
type: "link" as const,
href,
newTab: false,
};
},
},
];
}),
[data],
);
const plan = useMemo(
() =>
getBookmarkDisplayPlan({
advanced,
height: options.title.length > 0 ? height - 36 : height,
itemCount: data.length,
layout: options.layout,
width,
}),
[advanced, data.length, height, options.layout, options.title.length, width],
);
const cardDisplay = getBookmarkCardDisplay({
advanced,
hideHostname: options.hideHostname,
hideIcon: options.hideIcon,
hideTitle: options.hideTitle,
plan,
});
const cards = data.map((bookmark) => (
<BookmarkCard
key={bookmark.id}
bookmark={bookmark}
advanced={advanced}
orientation={cardDisplay.orientation}
showHostname={cardDisplay.showHostname}
showIcon={cardDisplay.showIcon}
showTitle={cardDisplay.showTitle}
openNewTab={options.openNewTab}
variant={options.variant}
withBorder={options.withBorder}
radius={board.itemRadius}
height={plan.itemHeight}
width={plan.horizontalScroll ? plan.itemWidth : undefined}
/>
));
return (
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
{options.title.length > 0 ? (
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
{options.title}
</Text>
) : null}
{data.length === 0 ? (
<Center flex={1}>
<Stack align="center" gap={6}>
<ThemeIcon variant="light" size="lg" radius="xl">
<IconBookmark size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center">
{t("empty")}
</Text>
</Stack>
</Center>
) : (
<ScrollArea
type="hover"
scrollbars={plan.horizontalScroll ? "x" : "y"}
scrollbarSize={2}
scrollHideDelay={500}
offsetScrollbars
style={{ flex: 1, minHeight: 0 }}
>
<Box miw="100%" mih="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
{cards}
</Group>
) : (
<SimpleGrid cols={plan.columns} spacing={options.spacing} verticalSpacing={options.spacing}>
{cards}
</SimpleGrid>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
}
interface BookmarkCardProps {
advanced: boolean;
bookmark: BookmarkItem;
height: number;
openNewTab: boolean;
orientation: BookmarkOrientation;
radius: string;
showHostname: boolean;
showIcon: boolean;
showTitle: boolean;
variant: BookmarkVariant;
width?: number;
withBorder: boolean;
}
const BookmarkCard = ({
advanced,
bookmark,
height,
openNewTab,
orientation,
radius,
showHostname,
showIcon,
showTitle,
variant,
width,
withBorder,
}: BookmarkCardProps) => {
const [active, setActive] = useState(false);
const reduceMotion = useReducedMotion();
const href = getSafeAppHref(bookmark.href);
const hostname = bookmark.href ? getBookmarkHostname(bookmark.href) : undefined;
const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href);
const iconOnly = orientation === "icon" || (!showTitle && !showHostname);
const background = getBookmarkBackground(variant, active);
const content =
orientation === "vertical" && !advanced ? (
<Stack h="100%" align="center" justify="center" gap={6} flex={1}>
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={iconOnly ? 34 : 38} /> : null}
{showTitle ? (
<Text size="xxs" fw={600} lh={1.2} ta="center" lineClamp={2}>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={9} c="dimmed" ta="center" truncate w="100%">
{hostname}
</Text>
) : null}
</Stack>
) : (
<Group
h="100%"
gap={advanced ? "sm" : "xs"}
wrap="nowrap"
justify={iconOnly ? "center" : "flex-start"}
align="center"
flex={1}
>
{showIcon ? (
<BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={advanced ? 42 : iconOnly ? 32 : 30} />
) : null}
{!iconOnly ? (
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
{showTitle ? (
<Text size={advanced ? "xs" : "xxs"} fw={600} lh={1.2} truncate>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={advanced ? 10 : 9} c="dimmed" truncate>
{hostname}
</Text>
) : null}
{advanced && bookmark.description ? (
<Text fz={10} c="dimmed" lineClamp={2}>
{bookmark.description}
</Text>
) : null}
</Stack>
) : null}
{advanced ? (
<ThemeIcon
variant={active ? "light" : "transparent"}
color={active ? "primaryColor" : "gray"}
size="sm"
radius="xl"
style={{
opacity: active ? 1 : 0.45,
transform: active && !reduceMotion ? "translate(1px, -1px)" : undefined,
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
}}
>
<IconArrowUpRight size={14} />
</ThemeIcon>
) : null}
</Group>
);
return (
<Tooltip
label={
<Stack gap={0}>
<Text size="xs" fw={600}>
{bookmark.name}
</Text>
{hostname ? (
<Text size="xs" opacity={0.8}>
{hostname}
</Text>
) : null}
</Stack>
}
openDelay={450}
disabled={advanced}
withArrow
>
<Card
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
aria-label={bookmark.name}
radius={radius}
withBorder={withBorder || variant === "outline"}
p={iconOnly ? 6 : advanced ? "md" : "xs"}
h={height}
w={width}
miw={width}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
styles={{
root: {
background,
borderColor: active
? "rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.62))"
: "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38))",
boxShadow: active ? "var(--mantine-shadow-sm)" : undefined,
color: "var(--mantine-color-text)",
cursor: href ? "pointer" : "default",
display: "flex",
outline: active ? "2px solid var(--mantine-primary-color-light)" : undefined,
outlineOffset: -2,
overflow: "hidden",
textDecoration: "none",
transform: active && !reduceMotion ? "translateY(-1px)" : undefined,
transition: reduceMotion
? undefined
: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease",
},
}}
>
{content}
</Card>
</Tooltip>
);
};
const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => (
<Avatar
src={iconUrl}
name={bookmark.name}
color="gray"
radius="sm"
size={size}
imageProps={{ referrerPolicy: "no-referrer" }}
styles={{ image: { objectFit: "contain" } }}
>
<IconLink size={Math.max(14, size / 2)} />
</Avatar>
);
const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => {
if (variant === "plain" || variant === "outline") return "transparent";
if (variant === "filled") {
const opacity = active ? 0.64 : 0.48;
return `rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * ${opacity}))`;
}
const opacity = active ? 0.14 : 0.08;
return `rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * ${opacity}))`;
};
const getBookmarkHostname = (href: string): string | undefined => {
try {
return new URL(href).hostname || undefined;
} catch {
return undefined;
}
};
+1 -530
View File
@@ -1,532 +1,3 @@
"use client";
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconLink } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
const COMPACT_GRID_GAP = 4;
export interface BookmarkDisplayItem {
id: string;
name: string;
description: string | null;
iconUrl?: string;
href: string | null;
}
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
hideTitle: boolean;
minimumItemSize: number;
}
export function getCompactBookmarkLayout(
width: number,
height: number,
itemCount: number,
layout: BookmarkLayout,
): CompactBookmarkLayout {
const count = Math.max(1, itemCount);
const isGrid = layout === "grid" || layout === "gridHorizontal";
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
let columns = 1;
if (isGrid) {
columns = Math.max(1, Math.min(count, Math.floor((width + COMPACT_GRID_GAP) / (targetWidth + COMPACT_GRID_GAP))));
} else if (layout === "row") {
columns = count;
}
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = (width - (columns - 1) * COMPACT_GRID_GAP) / columns;
const cellHeight = height / rows;
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
return {
columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemSize: usesHorizontalItems ? 48 : 96,
};
}
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { ref: compactContentRef, width: compactContentWidth } = useElementSize<HTMLDivElement>();
const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
select(selectedApps) {
return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
const customLinks = useMemo(
() => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
[options.customUrls],
);
const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data.flatMap((app) => {
const href = getSafeAppHref(app.href);
return href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl ?? IconLink,
interaction() {
return {
type: "link",
href,
newTab: false,
};
},
},
]
: [];
}),
[data],
);
const compactLayout = useMemo(
() => getCompactBookmarkLayout(compactContentWidth || width, height, data.length, options.layout),
[compactContentWidth, data.length, height, options.layout, width],
);
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
const compactHideHostname =
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
return (
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
{options.title}
</Title>
)}
{displayMode === "advanced" && (
<AdvancedBookmarksLayout
data={data}
width={width}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && (
<ScrollArea
classNames={{ viewport: classes.compactScrollViewport }}
type="auto"
scrollbarSize={6}
style={{ flex: 1, minHeight: 0 }}
>
<div ref={compactContentRef} className={classes.compactScrollContent}>
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
) : (
<FlexLayout
data={data}
direction={options.layout}
minimumItemSize={compactLayout.minimumItemSize}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
</div>
</ScrollArea>
)}
</Stack>
);
}
interface FlexLayoutProps {
data: BookmarkDisplayItem[];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
minimumItemSize: number;
}
const FlexLayout = ({
data,
direction,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
withBorder,
hasIconColor,
minimumItemSize,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
w="100%"
>
<Card
radius={board.itemRadius}
className={classes.card}
bg="transparent"
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
);
})}
</Flex>
);
};
interface AdvancedBookmarksLayoutProps {
data: BookmarkDisplayItem[];
width: number;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
const safeHref = getSafeApplicationUrl(href);
return safeHref ? new URL(safeHref).hostname : undefined;
};
export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
const normalizedUrl = url.trim();
const href = getSafeAppHref(normalizedUrl);
if (!href) return null;
return {
id: `custom-link:${normalizedUrl}`,
name: getBookmarkHostname(href) ?? normalizedUrl,
description: null,
href,
};
};
const AdvancedBookmarksLayout = ({
data,
width,
openNewTab,
withBorder,
hasIconColor,
}: AdvancedBookmarksLayoutProps) => {
const board = useRequiredBoard();
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
>
<Card
radius={board.itemRadius}
className={classes.card}
bg="transparent"
withBorder={withBorder}
p="md"
h="100%"
>
<Group align="flex-start" wrap="nowrap">
<BookmarkIcon
app={app}
hasColor={hasIconColor}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed">
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
</ScrollArea>
);
};
interface GridLayoutProps {
data: BookmarkDisplayItem[];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
columns: number;
minimumItemHeight: number;
}
const GridLayout = ({
data,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
withBorder,
itemDirection,
hasIconColor,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex gap={COMPACT_GRID_GAP} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
style={{
flex: `1 0 calc((100% - calc(${COMPACT_GRID_GAP * (columns - 1)}px * var(--mantine-scale, 1))) / ${columns})`,
minWidth: 0,
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
}}
>
<Card
h="100%"
className={classes.card}
bg="transparent"
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
);
})}
</Flex>
);
};
const VerticalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs" lineClamp={2}>
{app.name}
</Text>
)}
{!hideIcon && (
<BookmarkIcon
app={app}
hasColor={hasIconColor}
className={classes.bookmarkIcon}
style={{
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
overflow: "auto",
flex: "unset",
marginLeft: "auto",
marginRight: "auto",
}}
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
);
};
const HorizontalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
{!hideIcon && (
<BookmarkIcon
app={app}
hasColor={hasIconColor}
className={classes.bookmarkIcon}
style={{
overflow: "auto",
width: hideHostname ? 16 : 24,
height: hideHostname ? 16 : 24,
flex: "unset",
}}
/>
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
</Text>
)}
{!hideHostname && (
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
</>
)}
</Group>
);
};
interface BookmarkIconProps {
app: BookmarkDisplayItem;
hasColor: boolean;
className?: string;
style?: CSSProperties;
}
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
app.iconUrl ? (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasColor}
alt={app.name}
className={className}
style={style}
/>
) : (
<IconLink aria-label={app.name} className={className} style={style} />
);
export { default } from "./bookmarks-widget";
+46 -19
View File
@@ -1,14 +1,13 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
import { z } from "zod/v4";
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getSafeAppHref } from "../common/application-url";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
import type { BookmarkSelectionItem } from "./add-button";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
@@ -18,42 +17,63 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
return optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
value,
label: (t) => {
if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label");
if (value === "grid") return t("widget.common.layout.option.grid");
if (value === "row") return t("widget.common.layout.option.horizontal");
if (value === "column") return t("widget.common.layout.option.vertical");
if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label");
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
},
})),
defaultValue: "column",
defaultValue: "adaptive",
}),
variant: factory.select({
options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`),
})),
defaultValue: "soft",
}),
spacing: factory.select({
options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`),
})),
defaultValue: "xs",
}),
hideTitle: factory.switch({ defaultValue: false }),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
customUrls: factory.multiText({
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
withDescription: true,
}),
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
customUrls: factory.internal({ defaultValue: [] as string[] }),
items: factory.sortableItemList<BookmarkSelectionItem, string>({
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Group wrap="nowrap" miw={0}>
{handle}
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
<Group wrap="nowrap" miw={0}>
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
<IconLink size={20} />
</Avatar>
<Stack gap={0} miw={0}>
<Text fw={600} truncate>
{item.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{item.href}
</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
<IconX size="var(--mantine-font-size-xl)" />
</ActionIcon>
</Group>
@@ -62,7 +82,14 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value));
const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds);
const appsById = new Map(apps?.map((app) => [app.id, app]));
const data = initialIds.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
if (directUrl) return createDirectBookmark(directUrl) ?? [];
return appsById.get(value) ?? [];
});
return {
data,
+49 -34
View File
@@ -1,54 +1,69 @@
import { describe, expect, test } from "vitest";
import {
createCustomBookmark,
getAdvancedBookmarkColumns,
getBookmarkHostname,
getCompactBookmarkLayout,
} from "./component";
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
test("uses explicit width breakpoints for advanced columns", () => {
expect(
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
).toMatchObject({
columns: 3,
orientation: "horizontal",
});
expect(
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "horizontal",
});
});
test("uses container geometry to keep compact cards legible", () => {
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
test("selects an adaptive plan from width and height breakpoints", () => {
expect(
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
columns: 2,
hideHostname: false,
hideTitle: false,
minimumItemSize: 48,
itemHeight: 128,
orientation: "horizontal",
showHostname: true,
showTitle: true,
});
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
columns: 4,
hideHostname: true,
hideTitle: true,
minimumItemSize: 96,
expect(
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "icon",
showHostname: false,
showTitle: false,
});
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
test("keeps compact cards visible when icons are hidden", () => {
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
expect(
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
).toEqual({
orientation: "horizontal",
showHostname: false,
showIcon: false,
showTitle: true,
});
});
test("creates standalone bookmarks without app records", () => {
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
id: "custom-link:https://homarr.dev/docs",
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
id: "url:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
id: "custom-link:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
});
test("splits URL lists without corrupting commas inside a URL", () => {
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
});
});
+265
View File
@@ -0,0 +1,265 @@
import type { WidgetComponentProps } from "../definition";
export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export type BookmarkOrientation = "horizontal" | "vertical" | "icon";
export interface BookmarkDisplayPlan {
columns: number;
horizontalScroll: boolean;
itemHeight: number;
itemWidth: number;
orientation: BookmarkOrientation;
showHostname: boolean;
showTitle: boolean;
}
export interface BookmarkCardDisplay {
orientation: BookmarkOrientation;
showHostname: boolean;
showIcon: boolean;
showTitle: boolean;
}
export const getBookmarkCardDisplay = ({
advanced,
hideHostname,
hideIcon,
hideTitle,
plan,
}: {
advanced: boolean;
hideHostname: boolean;
hideIcon: boolean;
hideTitle: boolean;
plan: BookmarkDisplayPlan;
}): BookmarkCardDisplay => {
let orientation = plan.orientation;
let showTitle = advanced || (!hideTitle && plan.showTitle);
if (!advanced && hideIcon && !hideTitle && !showTitle) {
orientation = "horizontal";
showTitle = true;
}
const showHostname = advanced || (!hideHostname && plan.showHostname);
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
return { orientation, showHostname, showIcon, showTitle };
};
interface BookmarkWidthBreakpoint {
adaptiveColumns: number;
adaptiveOrientation: BookmarkOrientation;
advancedColumns: number;
compactColumns: number;
gridColumns: number;
iconColumns: number;
minWidth: number;
rowItemWidth: number;
showHostname: boolean;
showTitle: boolean;
}
const widthBreakpoints = [
{
minWidth: 900,
adaptiveColumns: 4,
advancedColumns: 3,
compactColumns: 4,
gridColumns: 5,
iconColumns: 10,
rowItemWidth: 180,
adaptiveOrientation: "vertical",
showHostname: true,
showTitle: true,
},
{
minWidth: 720,
adaptiveColumns: 3,
advancedColumns: 2,
compactColumns: 3,
gridColumns: 4,
iconColumns: 8,
rowItemWidth: 172,
adaptiveOrientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minWidth: 480,
adaptiveColumns: 2,
advancedColumns: 2,
compactColumns: 2,
gridColumns: 3,
iconColumns: 6,
rowItemWidth: 160,
adaptiveOrientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minWidth: 300,
adaptiveColumns: 2,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 2,
iconColumns: 4,
rowItemWidth: 144,
adaptiveOrientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minWidth: 0,
adaptiveColumns: 1,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 1,
iconColumns: 2,
rowItemWidth: 112,
adaptiveOrientation: "icon",
showHostname: false,
showTitle: false,
},
] as const satisfies readonly BookmarkWidthBreakpoint[];
interface BookmarkHeightBreakpoint {
columnItemHeight: number;
itemHeight: number;
minHeight: number;
orientation: BookmarkOrientation;
rowItemHeight: number;
showHostname: boolean;
showTitle: boolean;
}
const heightBreakpoints = [
{
minHeight: 300,
columnItemHeight: 72,
itemHeight: 128,
rowItemHeight: 128,
orientation: "vertical",
showHostname: true,
showTitle: true,
},
{
minHeight: 180,
columnItemHeight: 64,
itemHeight: 128,
rowItemHeight: 112,
orientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minHeight: 110,
columnItemHeight: 56,
itemHeight: 80,
rowItemHeight: 80,
orientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minHeight: 0,
columnItemHeight: 48,
itemHeight: 56,
rowItemHeight: 56,
orientation: "icon",
showHostname: false,
showTitle: false,
},
] as const satisfies readonly BookmarkHeightBreakpoint[];
export const getBookmarkDisplayPlan = ({
advanced,
height,
itemCount,
layout,
width,
}: {
advanced: boolean;
height: number;
itemCount: number;
layout: BookmarkLayout;
width: number;
}): BookmarkDisplayPlan => {
const count = Math.max(1, itemCount);
const normalizedWidth = Math.max(0, width);
const normalizedHeight = Math.max(0, height);
const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth);
const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight);
if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes");
if (advanced) {
return {
columns: Math.min(count, widthSettings.advancedColumns),
horizontalScroll: false,
itemHeight: 104,
itemWidth: 260,
orientation: "horizontal",
showHostname: true,
showTitle: true,
};
}
if (layout === "row") {
let orientation: BookmarkOrientation = "vertical";
if (!heightSettings.showTitle) orientation = "icon";
return {
columns: count,
horizontalScroll: true,
itemHeight: heightSettings.rowItemHeight,
itemWidth: widthSettings.rowItemWidth,
orientation,
showHostname: widthSettings.showHostname && heightSettings.showHostname,
showTitle: widthSettings.showTitle && heightSettings.showTitle,
};
}
if (layout === "column") {
return {
columns: 1,
horizontalScroll: false,
itemHeight: heightSettings.columnItemHeight,
itemWidth: width,
orientation: "horizontal",
showHostname: widthSettings.showHostname && heightSettings.showHostname,
showTitle: widthSettings.showTitle && heightSettings.showTitle,
};
}
if (layout === "icons") {
return {
columns: Math.min(count, widthSettings.iconColumns),
horizontalScroll: false,
itemHeight: 56,
itemWidth: 56,
orientation: "icon",
showHostname: false,
showTitle: false,
};
}
let columns: number = widthSettings.compactColumns;
let orientation: BookmarkOrientation = "horizontal";
if (layout === "grid") {
columns = widthSettings.gridColumns;
orientation = "vertical";
}
if (layout === "adaptive") {
columns = widthSettings.adaptiveColumns;
orientation = widthSettings.adaptiveOrientation;
if (heightSettings.orientation === "icon") orientation = "icon";
if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal";
}
return {
columns: Math.min(count, columns),
horizontalScroll: false,
itemHeight: heightSettings.itemHeight,
itemWidth: widthSettings.rowItemWidth,
orientation,
showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname,
showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle,
};
};
+6 -4
View File
@@ -4,11 +4,12 @@ import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
import { getDirectBookmarkUrl } from "./bookmark-item";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
const appIds = items.flatMap((item) => item.options.items);
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
@@ -16,13 +17,14 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
});
for (const item of items) {
if (item.options.items.length === 0) {
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
if (itemAppIds.length === 0) {
continue;
}
queryClient.setQueryData(
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
createTrpcQueryKey("app.byIds", itemAppIds),
dbApps.filter((app) => itemAppIds.includes(app.id)),
);
}
@@ -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;
}
}
+229 -189
View File
@@ -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,
});
+3
View File
@@ -34,12 +34,15 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
> {
AddButton: (props: {
addItem: (item: TItem) => void;
migrateItems: (items: TItem[], optionsPatch: Record<string, unknown>) => void;
removeItem: (value: TOptionValue) => void;
values: TOptionValue[];
initialOptions: Record<string, unknown>;
}) => React.ReactNode;
ItemComponent: (props: {
item: TItem;
removeItem: () => void;
removeLabel: string;
rootAttributes: DraggableAttributes;
handle: React.ReactNode;
}) => React.ReactNode;
@@ -69,13 +69,13 @@ describe("weather layout", () => {
});
describe("weather defaults", () => {
it("starts rich and static while preserving saved false values", () => {
it("starts minimal and static while preserving saved false values", () => {
const options = definition.createOptions();
expect(options.showCity.defaultValue).toBe(true);
expect(options.hasForecast.defaultValue).toBe(true);
expect(options.showCity.defaultValue).toBe(false);
expect(options.hasForecast.defaultValue).toBe(false);
expect(options.forecastDayCount.defaultValue).toBe(3);
expect(options.animateIcons.defaultValue).toBe(false);
expect(options.showHumidity.defaultValue).toBe(true);
expect(options.showHumidity.defaultValue).toBe(false);
const reduced = reduceWidgetOptionsWithDefinition(definition, {} as never, {
showCity: false,
+4 -4
View File
@@ -26,8 +26,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
isFormatFahrenheit: factory.switch(),
disableTemperatureDecimals: factory.switch(),
animateIcons: factory.switch(),
showHumidity: factory.switch({ defaultValue: true, withDescription: true }),
showCurrentWindSpeed: factory.switch({ withDescription: true }),
showHumidity: factory.switch({ defaultValue: false }),
showCurrentWindSpeed: factory.switch(),
useImperialSpeed: factory.switch(),
location: factory.location({
defaultValue: {
@@ -50,8 +50,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch({ defaultValue: true }),
hasForecast: factory.switch({ defaultValue: true }),
showCity: factory.switch({ defaultValue: false }),
hasForecast: factory.switch({ defaultValue: false }),
forecastDayCount: factory.slider({
defaultValue: 3,
validate: z.number().min(1).max(7),