Compare commits

..
Author SHA1 Message Date
Thomas Camlong d2b21d0881 fix: position calendar error indicator 2026-08-22 16:56:12 +02:00
57 changed files with 1015 additions and 1759 deletions

No files matched your search

@@ -194,15 +194,11 @@ To configure this, click on the small 3 dots at the top of the item in the edit
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
</figure>
The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
In the modal you can edit the app that you want to display:
<figure>
<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>
<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>
</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: 144 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+3 -4
View File
@@ -27,10 +27,9 @@ 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 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.
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.
## Create a new board
+6 -7
View File
@@ -1,6 +1,6 @@
---
title: "Bookmarks"
description: "Keeps useful links together"
description: "Displays multiple app links"
hide_title: true
---
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
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.
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.
### Screenshots
+12 -18
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark,
name: "Bookmarks",
description: "Keeps useful links together",
description: "Displays multiple app links",
path: "../../widgets/bookmarks",
configuration: {
items: [
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Layout",
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",
description: "Layout in which the bookmarks are displayed.",
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
defaultValue: "Vertical",
},
{
name: "Hide title",
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Bookmarks",
description: "URLs and existing Apps shown by the widget.",
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
description: "List of bookmarks to display in the widget.",
values: "Select one or more Apps directly, then order them as you like.",
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, useId, useState } from "react";
import { use, useCallback, 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,7 +32,6 @@ 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>({
@@ -59,7 +58,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
preloadWidgetEditModal();
try {
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
const originalState = state;
openWidgetEditModal(
{
kind,
@@ -68,14 +66,11 @@ 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) {
@@ -91,7 +86,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
@@ -115,7 +110,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
return (
<>
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
@@ -1,6 +1,5 @@
.canvas {
width: 100%;
min-width: 0;
min-height: 8rem;
aspect-ratio: 16 / 7;
overflow: hidden;
color: var(--mantine-color-dimmed);
@@ -84,3 +83,9 @@
gap: rem(1px);
overflow: hidden;
}
@media (max-width: $mantine-breakpoint-xs) {
.canvas {
min-height: 7rem;
}
}
@@ -11,12 +11,11 @@ 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, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
return (
@@ -27,7 +26,6 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
}
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));
@@ -67,12 +65,12 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
element.xOffset >= 0 &&
element.xOffset < columnCount &&
element.yOffset >= 0 &&
element.yOffset < thumbnailRowLimit &&
element.yOffset < maxThumbnailRows &&
element.width > 0 &&
element.height > 0,
);
const rowCount = Math.min(
thumbnailRowLimit,
maxThumbnailRows,
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
);
@@ -1,26 +1,7 @@
.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);
@@ -30,12 +11,6 @@
.card {
height: 100%;
overflow: hidden;
box-shadow: var(--mantine-shadow-sm);
}
.metadataBadge {
width: rem(22px);
padding-inline: 0;
}
.option:hover .card,
@@ -3,31 +3,29 @@
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 { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } 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, UserAvatar } from "@homarr/ui";
import { Link } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -41,7 +39,6 @@ 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("");
@@ -53,27 +50,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
},
);
} = clientApi.board.getManageOverview.useQuery(undefined, {
enabled: isOpen,
});
const switcherBoards = useMemo(
() => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
const availableBoards = useMemo(
() => boards.filter((board) => board.id !== currentBoard?.id),
[boards, currentBoard?.id],
);
const filteredBoards = useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase();
if (normalizedSearch.length === 0) return switcherBoards;
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
}, [search, switcherBoards]);
if (normalizedSearch.length === 0) return availableBoards;
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
}, [availableBoards, search]);
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);
@@ -83,7 +73,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
@@ -98,17 +88,14 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, [activeIndex, filteredBoards.length, isOpen]);
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const isSearchInput =
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
if (!isSearchInput && event.key === "Backspace") {
if (event.key === "Backspace") {
event.preventDefault();
setSearch((current) => current.slice(0, -1));
setActiveIndex(0);
return;
}
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setSearch((current) => `${current}${event.key}`);
setActiveIndex(0);
@@ -154,7 +141,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
};
const results = getBoardSwitcherResults({
availableBoardCount: switcherBoards.length,
availableBoardCount: availableBoards.length,
filteredBoards,
isError,
isPending,
@@ -165,7 +152,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setActiveIndex,
optionRefs,
columnCount,
manageBoardsT,
});
return (
@@ -175,40 +161,54 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
<Modal
opened={isOpen}
onClose={closeSwitcher}
withCloseButton={false}
yOffset="15vh"
aria-label={t("title")}
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
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 }}
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
classNames={{ content: classes.modalContent, body: classes.modalBody }}
>
<Stack
data-autofocus
tabIndex={-1}
gap="xs"
gap="md"
onKeyDown={handleModalKeyDown}
className={classes.content}
aria-describedby="board-switcher-instructions"
>
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
<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">
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
{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,7 +227,6 @@ interface BoardSwitcherResultsProps {
setActiveIndex: (index: number) => void;
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
columnCount: number;
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
}
const getBoardSwitcherResults = ({
@@ -242,7 +241,6 @@ const getBoardSwitcherResults = ({
setActiveIndex,
optionRefs,
columnCount,
manageBoardsT,
}: BoardSwitcherResultsProps) => {
if (isPending) {
return (
@@ -289,7 +287,7 @@ const getBoardSwitcherResults = ({
}
return (
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
{filteredBoards.map((board, index) => (
<UnstyledButton
key={board.id}
@@ -298,7 +296,7 @@ const getBoardSwitcherResults = ({
}}
className={classes.option}
w="100%"
maw={240}
maw={320}
mx="auto"
component={Link}
href={`/boards/${encodeURIComponent(board.name)}`}
@@ -308,50 +306,21 @@ const getBoardSwitcherResults = ({
onFocus={() => setActiveIndex(index)}
onPointerEnter={() => setActiveIndex(index)}
>
<Card padding={0} withBorder radius="lg" className={classes.card}>
<Card padding={0} withBorder radius="md" 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 px="sm" py={6}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fw={600} size="sm" truncate>
<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>
{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,13 +30,6 @@ 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();
@@ -84,20 +77,6 @@ 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 }));
@@ -126,7 +105,6 @@ export const useItemActions = () => {
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
updateItemSettings,
duplicateItem,
createItem,
};
@@ -297,7 +297,6 @@ 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,7 +3,6 @@ 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";
@@ -34,7 +33,6 @@ 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");
@@ -42,7 +40,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
@@ -56,11 +55,6 @@ 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,
@@ -70,14 +64,19 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
options: item.options,
integrationIds: item.integrationIds,
},
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (value) => {
updateItemSettings({ itemId: item.id, ...value });
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
});
updateItemAdvancedOptions({
itemId: item.id,
newAdvancedOptions: advancedOptions,
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true;
},
onIntegrationSaved: resetErrorBoundary,
@@ -90,7 +89,6 @@ 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,7 +277,6 @@ const ItemSelectModalContent = ({
integrationData: [],
integrationSupport: false,
settings,
boardId: board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -347,7 +346,6 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
boardId: board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
@@ -157,8 +157,6 @@ 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 { updateItemSettings } = useItemActions();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
@@ -154,27 +154,19 @@ 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,
options: item.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) =>
@@ -197,8 +189,6 @@ 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
@@ -207,16 +197,16 @@ export const WidgetContextMenu = ({
},
);
}, [
board.id,
definition,
integrationData,
item,
handleRefetch,
openModal,
options,
persistBoard,
settings,
updateItemSettings,
updateItemAdvancedOptions,
updateItemIntegrations,
updateItemOptions,
]);
const handleToggle = useCallback(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

+4 -5
View File
@@ -86,7 +86,6 @@ 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();
@@ -226,7 +225,7 @@ export const boardRouter = createTRPCRouter({
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
}));
}),
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
getManageOverview: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
@@ -307,7 +306,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
@@ -332,7 +331,7 @@ export const boardRouter = createTRPCRouter({
inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0),
),
@@ -367,7 +366,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, previewContainerSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
+1 -5
View File
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
});
test("returns compact previews by default and full previews on request", async () => {
test("returns one compact base-layout preview for each accessible board", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const boardId = createId();
@@ -487,10 +487,6 @@ describe("getManageOverview", () => {
expect(result[0]?.preview?.layouts).toHaveLength(1);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
expect(result[0]?.preview?.sections).toHaveLength(2);
const fullResult = await caller.getManageOverview({ fullPreview: true });
expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]);
});
test("includes app and widget icons in compact previews", async () => {
@@ -7,11 +7,9 @@
.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;
}
@@ -25,13 +23,8 @@
--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,7 +19,6 @@ export type CreateModalOptions = Pick<
| "centered"
| "keepMounted"
| "withCloseButton"
| "withOverlay"
| "zIndex"
| "scrollAreaComponent"
| "yOffset"
+6 -53
View File
@@ -2192,51 +2192,8 @@
"layout": {
"label": "Layout",
"option": {
"adaptive": {
"label": "Adaptive"
},
"gridHorizontal": {
"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"
"label": "Grid horizontal"
}
}
},
@@ -2253,21 +2210,17 @@
"label": "Show border"
},
"customUrls": {
"label": "Custom links"
"label": "Custom links",
"description": "Add URLs directly without creating an App first"
},
"items": {
"label": "Bookmarks",
"remove": "Remove bookmark",
"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."
"label": "Apps",
"placeholder": "Select one or more apps"
}
}
},
"empty": "Add a URL or App in the widget settings"
}
},
"archiveTeamWarrior": {
"name": "ArchiveTeam Warrior",
-1
View File
@@ -25,7 +25,6 @@ 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" | "remove") => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
/**
* Short description why as and unknown convertions are used below:
@@ -96,28 +96,6 @@ 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));
@@ -134,13 +112,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton
addItem={addItem}
migrateItems={migrateItems}
removeItem={removeItem}
values={values}
initialOptions={initialOptions}
/>
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
<DndContext
sensors={sensors}
@@ -181,7 +153,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
index={index}
item={item}
removeItem={() => removeItem(value)}
removeLabel={t("remove")}
options={options}
/>
);
@@ -206,7 +177,6 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
item: TItem;
index: number;
removeItem: () => void;
removeLabel: string;
options: CommonWidgetInputProps<"sortableItemList">["options"];
}
@@ -215,7 +185,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
index,
item,
removeItem,
removeLabel,
options,
}: ItemProps<TItem, TOptionValue>) => {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
@@ -251,7 +220,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
key={index}
item={item}
removeItem={removeItem}
removeLabel={removeLabel}
rootAttributes={attributes}
handle={
<ActionIcon
+53 -172
View File
@@ -1,198 +1,79 @@
"use client";
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 { MultiSelectProps } from "@mantine/core";
import { Group, Loader, MultiSelect } from "@mantine/core";
import { IconCheck } 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;
const createOptionValue = "$create-url";
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
addItem,
migrateItems,
removeItem,
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 [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 appsById = new Map(apps.map((app) => [app.id, app]));
useEffect(() => {
if (migrationStartedRef.current || legacyUrls.length === 0) return;
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 handleChange = (nextValues: string[]) => {
const nextIds = new Set(nextValues);
for (const value of values) {
if (!nextIds.has(value)) removeItem(value);
}
const app = apps.find((candidate) => candidate.id === value);
if (app) addItem(app);
setSearch("");
const currentIds = new Set(values);
for (const value of nextValues) {
if (currentIds.has(value)) continue;
const app = appsById.get(value);
if (app) addItem(app);
}
};
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 (
<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>
<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,
}))}
/>
);
};
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>
);
@@ -1,66 +0,0 @@
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];
});
@@ -0,0 +1,45 @@
.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%;
}
@@ -1,368 +0,0 @@
"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;
}
};
+530 -1
View File
@@ -1,3 +1,532 @@
"use client";
export { default } from "./bookmarks-widget";
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} />
);
+19 -46
View File
@@ -1,13 +1,14 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
import { IconBookmark, IconX } from "@tabler/icons-react";
import { z } from "zod/v4";
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,
@@ -17,63 +18,42 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
return optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
options: (["grid", "gridHorizontal", "row", "column"] 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: "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",
defaultValue: "column",
}),
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.internal({ defaultValue: [] as string[] }),
items: factory.sortableItemList<BookmarkSelectionItem, string>({
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
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 }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap" miw={0}>
<Group wrap="nowrap">
{handle}
<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>
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size="var(--mantine-font-size-xl)" />
</ActionIcon>
</Group>
@@ -82,14 +62,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (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) ?? [];
});
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
+34 -49
View File
@@ -1,69 +1,54 @@
import { describe, expect, test } from "vitest";
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
import {
createCustomBookmark,
getAdvancedBookmarkColumns,
getBookmarkHostname,
getCompactBookmarkLayout,
} from "./component";
describe("bookmark display helpers", () => {
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 bounded advanced columns", () => {
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("selects an adaptive plan from width and height breakpoints", () => {
expect(
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
test("uses container geometry to keep compact cards legible", () => {
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
columns: 2,
itemHeight: 128,
orientation: "horizontal",
showHostname: true,
showTitle: true,
hideHostname: false,
hideTitle: false,
minimumItemSize: 48,
});
expect(
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "icon",
showHostname: false,
showTitle: false,
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
columns: 4,
hideHostname: true,
hideTitle: true,
minimumItemSize: 96,
});
});
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("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("creates standalone bookmarks without app records", () => {
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
id: "url: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(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"]);
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();
});
});
-265
View File
@@ -1,265 +0,0 @@
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,
};
};
+4 -6
View File
@@ -4,12 +4,11 @@ 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.filter((value) => !getDirectBookmarkUrl(value)));
const appIds = items.flatMap((item) => item.options.items);
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
@@ -17,14 +16,13 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
});
for (const item of items) {
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
if (itemAppIds.length === 0) {
if (item.options.items.length === 0) {
continue;
}
queryClient.setQueryData(
createTrpcQueryKey("app.byIds", itemAppIds),
dbApps.filter((app) => itemAppIds.includes(app.id)),
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
@@ -5,8 +5,8 @@
}
.errorIndicator {
top: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xl));
inset-inline-end: 4px;
top: 4px;
inset-inline-start: 4px;
z-index: 1;
padding: 2px;
border: 1px solid rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.45));
+2 -2
View File
@@ -293,7 +293,7 @@ const CalendarAgenda = ({
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
return (
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
<Stack h="100%" w="100%" gap="sm" p="md" pos="relative" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
@@ -329,7 +329,7 @@ const CalendarAgenda = ({
</ActionIcon>
</Tooltip>
</Group>
<Group gap={0}>
<Group className={classes.errorIndicator} gap={0} pos="absolute">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
</Group>
@@ -17,10 +17,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
const t = useI18n("item.edit");
const theme = useMantineTheme();
const form = useForm({
initialValues: {
...innerProps.advancedOptions,
title: innerProps.advancedOptions.title ?? "",
},
initialValues: innerProps.advancedOptions,
});
const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import classes from "./widget-edit-modal.module.css";
interface FocusRect {
top: number;
left: number;
width: number;
height: number;
}
export type WidgetInspectorSide = "left" | "right";
const focusGap = 6;
const getFocusRect = (element: HTMLElement): FocusRect | null => {
const rect = element.getBoundingClientRect();
const top = Math.max(4, rect.top - focusGap);
const left = Math.max(4, rect.left - focusGap);
const right = Math.min(window.innerWidth - 4, rect.right + focusGap);
const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap);
if (right <= left || bottom <= top) return null;
return {
top,
left,
width: right - left,
height: bottom - top,
};
};
const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => {
if (current === next) return true;
if (!current || !next) return false;
return (
current.top === next.top &&
current.left === next.left &&
current.width === next.width &&
current.height === next.height
);
};
export const useWidgetEditFocus = (itemId: string | undefined, focusTargetId: string | undefined) => {
const [focusRect, setFocusRect] = useState<FocusRect | null>(null);
useIsomorphicEffect(() => {
let element: HTMLElement | null = null;
if (focusTargetId) {
element = document.getElementById(focusTargetId);
} else if (itemId) {
element = document.querySelector<HTMLElement>(`[data-advanced-focus-item-id="${CSS.escape(itemId)}"]`);
element ??= document.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
}
if (!element) {
setFocusRect(null);
return;
}
let animationFrame = 0;
const update = () => {
animationFrame = 0;
const nextRect = getFocusRect(element);
setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect));
};
const scheduleUpdate = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(update);
};
update();
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(element);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
window.cancelAnimationFrame(animationFrame);
};
}, [focusTargetId, itemId]);
const inspectorSide: WidgetInspectorSide =
focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right";
return { focusRect, inspectorSide };
};
export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => (
<Portal>
{focusRect ? (
<>
<Overlay fixed backgroundOpacity={0} zIndex={getDefaultZIndex("modal")} />
<Box
aria-hidden
className={classes.focusFrame}
style={{
top: focusRect.top,
left: focusRect.left,
width: focusRect.width,
height: focusRect.height,
zIndex: getDefaultZIndex("modal"),
}}
/>
</>
) : (
<Overlay fixed color="#000" backgroundOpacity={0.58} blur={1} zIndex={getDefaultZIndex("modal")} />
)}
</Portal>
);
@@ -1,90 +0,0 @@
.panel {
min-width: 0;
}
.settings {
min-width: 0;
container-type: inline-size;
}
.switchGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: var(--mantine-spacing-sm);
}
.switchOption {
display: flex;
height: 100%;
min-height: 4.25rem;
align-items: center;
padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.switchOption > *,
.switchOption :global(.mantine-Switch-root),
.switchOption :global(.mantine-Switch-body) {
width: 100%;
}
.switchOption :global(.mantine-Switch-body) {
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-sm);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.footer {
position: sticky;
z-index: 2;
bottom: 0;
margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent);
backdrop-filter: blur(10px);
}
.footerActions {
margin-inline-start: auto;
}
@container (min-width: 30rem) {
.switchGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
}
}
.focusFrame {
position: fixed;
pointer-events: none;
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent),
0 0 0 100vmax rgb(0 0 0 / 0.58);
clip-path: inset(-100vmax);
transition:
top 160ms ease,
left 160ms ease,
width 160ms ease,
height 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.focusFrame {
transition: none;
}
}
+189 -229
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, 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, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
@@ -26,10 +26,8 @@ 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>;
@@ -52,14 +50,11 @@ 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;
@@ -86,18 +81,6 @@ 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),
@@ -129,47 +112,25 @@ 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: {
options: innerProps.value.options,
integrationIds: innerProps.value.integrationIds,
},
validate: schemaResolver(widgetFormSchema, { sync: true }),
initialValues: innerProps.value,
validate: schemaResolver(
z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
{ 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;
@@ -249,7 +210,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
...values,
advancedOptions,
});
hasSavedRef.current = true;
actions.closeModal();
} finally {
setIsSubmitting(false);
@@ -271,202 +231,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 = (
<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;
<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);
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;
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
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}
/>
);
})}
</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">
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={
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: (value) => {
setAdvancedOptions(value);
applyPreview({ ...form.getValues(), advancedOptions: value });
},
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap">
<Button type="button" onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
}
/>
)}
</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
)}
</FormProvider>
</>
</form>
);
}).withOptions({
keepMounted: true,
defaultTitle(t) {
return t("item.edit.title");
},
presentation: "inspector",
withOverlay: false,
size: modalSizeForm,
closeOnClickOutside: false,
});
-3
View File
@@ -34,15 +34,12 @@ 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;
+2 -2
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: false }),
showCurrentWindSpeed: factory.switch(),
showHumidity: factory.switch({ defaultValue: false, withDescription: true }),
showCurrentWindSpeed: factory.switch({ withDescription: true }),
useImperialSpeed: factory.switch(),
location: factory.location({
defaultValue: {