From 471aa0a49fc6e8718580bdd0bb91f4f0fdac8250 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 16:08:16 +0200 Subject: [PATCH] fix: address bookmarks review feedback --- packages/translation/src/lang/en.json | 4 + packages/widgets/src/_inputs/common.tsx | 2 +- .../widget-sortable-item-list-input.tsx | 34 +- packages/widgets/src/bookmarks/add-button.tsx | 34 +- .../widgets/src/bookmarks/bookmark-item.ts | 15 +- .../widgets/src/bookmarks/bookmark.module.css | 45 -- .../src/bookmarks/bookmarks-widget.tsx | 157 +----- packages/widgets/src/bookmarks/component.tsx | 531 ------------------ packages/widgets/src/bookmarks/index.tsx | 4 +- packages/widgets/src/bookmarks/layout.spec.ts | 83 +-- packages/widgets/src/bookmarks/layout.ts | 265 +++++++++ packages/widgets/src/options.ts | 2 + 12 files changed, 411 insertions(+), 765 deletions(-) delete mode 100644 packages/widgets/src/bookmarks/bookmark.module.css create mode 100644 packages/widgets/src/bookmarks/layout.ts diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 7d61e18d8..be9695a0f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2232,8 +2232,12 @@ "withBorder": { "label": "Show border" }, + "customUrls": { + "label": "Custom links" + }, "items": { "label": "Bookmarks", + "remove": "Remove bookmark", "apps": { "label": "Add bookmarks", "description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.", diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 8d56de939..446d9608f 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -16,7 +16,7 @@ export interface CommonWidgetInputProps { boardId?: string; } -type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string; +type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string; /** * Short description why as and unknown convertions are used below: diff --git a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx index 719598f52..afe0a63ba 100644 --- a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx +++ b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx @@ -96,6 +96,28 @@ export const WidgetSortedItemListInput = [...currentValues, options.uniqueIdentifier(item) as TOptionValue]); }; + const migrateItems = useCallback( + (items: TItem[], optionsPatch: Record) => { + const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const); + setTempMap((previous) => { + const next = new Map(previous); + for (const [value, item] of migratedEntries) next.set(value, item); + return next; + }); + form.setFieldValue("options", (currentOptions) => { + const currentItems = Array.isArray(currentOptions[property]) + ? (currentOptions[property] as TOptionValue[]) + : options.defaultValue; + const nextItems = [...currentItems]; + for (const [value] of migratedEntries) { + if (!nextItems.includes(value)) nextItems.push(value); + } + return { ...currentOptions, ...optionsPatch, [property]: nextItems }; + }); + }, + [form, options, property], + ); + const removeItem = useCallback( (value: TOptionValue) => { updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value)); @@ -112,7 +134,13 @@ export const WidgetSortedItemListInput = - + removeItem(value)} + removeLabel={t("remove")} options={options} /> ); @@ -177,6 +206,7 @@ interface ItemProps { item: TItem; index: number; removeItem: () => void; + removeLabel: string; options: CommonWidgetInputProps<"sortableItemList">["options"]; } @@ -185,6 +215,7 @@ const Item = ({ index, item, removeItem, + removeLabel, options, }: ItemProps) => { const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({ @@ -220,6 +251,7 @@ const Item = ({ key={index} item={item} removeItem={removeItem} + removeLabel={removeLabel} rootAttributes={attributes} handle={ ["AddButton"] = ({ addItem, + migrateItems, values, initialOptions, }) => { @@ -48,27 +44,26 @@ export const BookmarkAddButton: SortableItemListInput 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]); useEffect(() => { - for (const url of legacyUrls) { - const item = createDirectBookmark(url); - if (item && !selectedValues.has(item.id)) addItem(item); - } - }, [addItem, legacyUrls, selectedValues]); + if (migrationStartedRef.current || legacyUrls.length === 0) return; - const normalizedSearch = search.trim().toLocaleLowerCase(); + 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.toLocaleLowerCase().includes(normalizedSearch) || - app.href?.toLocaleLowerCase().includes(normalizedSearch) - ); + return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch); }); const pendingUrl = createDirectBookmark(search); @@ -143,7 +138,6 @@ export const BookmarkAddButton: SortableItemListInput combobox.openDropdown()} onBlur={() => combobox.closeDropdown()} onChange={(event) => { @@ -158,7 +152,7 @@ export const BookmarkAddButton: SortableItemListInput { - if (event.key !== "Enter" && event.key !== ",") return; + if (event.key !== "Enter") return; if (!pendingUrl) return; event.preventDefault(); addUrls(search); @@ -171,7 +165,7 @@ export const BookmarkAddButton: SortableItemListInput - {pendingUrl && !selectedValues.has(getDirectBookmarkValue(pendingUrl.href ?? "")) ? ( + {pendingUrl && !selectedValues.has(pendingUrl.id) ? ( diff --git a/packages/widgets/src/bookmarks/bookmark-item.ts b/packages/widgets/src/bookmarks/bookmark-item.ts index 6ca2115be..37b3a1e24 100644 --- a/packages/widgets/src/bookmarks/bookmark-item.ts +++ b/packages/widgets/src/bookmarks/bookmark-item.ts @@ -53,7 +53,14 @@ export const createDirectBookmark = (url: string): BookmarkItem | undefined => { }; export const splitBookmarkUrls = (value: string) => - value - .split(/[,\n]+/) - .map((part) => part.trim()) - .filter(Boolean); + 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]; + }); diff --git a/packages/widgets/src/bookmarks/bookmark.module.css b/packages/widgets/src/bookmarks/bookmark.module.css deleted file mode 100644 index cc5662ac5..000000000 --- a/packages/widgets/src/bookmarks/bookmark.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.card { - background-color: transparent; - border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38)); - transition: - background-color 120ms ease, - border-color 120ms ease; -} - -.card:hover, -.card:focus-within { - background-color: transparent; - border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62)); -} - -.bookmarkButton:focus-visible { - outline: 2px solid var(--mantine-primary-color-filled); - outline-offset: 2px; - border-radius: var(--mantine-radius-sm); -} - -.card:hover > div > div.bookmarkIcon { - background-color: var(--mantine-color-iconColor-filled-hover); -} - -.bookmarkButton:focus-visible .bookmarkIcon { - background-color: var(--mantine-color-iconColor-filled-hover); -} - -@media (prefers-reduced-motion: reduce) { - .card { - transition: none; - } -} - -.compactScrollViewport > div { - display: flex !important; - width: 100%; - min-height: 100%; -} - -.compactScrollContent { - flex: 1; - min-width: 100%; - min-height: 100%; -} diff --git a/packages/widgets/src/bookmarks/bookmarks-widget.tsx b/packages/widgets/src/bookmarks/bookmarks-widget.tsx index c7d222b89..56d90e132 100644 --- a/packages/widgets/src/bookmarks/bookmarks-widget.tsx +++ b/packages/widgets/src/bookmarks/bookmarks-widget.tsx @@ -27,121 +27,10 @@ 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 BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"]; -type BookmarkOrientation = "horizontal" | "vertical" | "icon"; - -interface BookmarkDisplayPlan { - columns: number; - horizontalScroll: boolean; - itemHeight: number; - itemWidth: number; - orientation: BookmarkOrientation; - showHostname: boolean; - showTitle: boolean; -} - -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 availableWidth = Math.max(1, width - 16); - const availableHeight = Math.max(1, height - 16); - - if (advanced) { - const columns = Math.max(1, Math.min(count, Math.floor(availableWidth / 260))); - return { - columns, - horizontalScroll: false, - itemHeight: 104, - itemWidth: 260, - orientation: "horizontal", - showHostname: true, - showTitle: true, - }; - } - - if (layout === "row") { - const itemWidth = Math.max(96, Math.min(180, availableWidth / Math.min(count, 4))); - return { - columns: count, - horizontalScroll: true, - itemHeight: Math.max(48, availableHeight), - itemWidth, - orientation: availableHeight < 64 ? "icon" : "vertical", - showHostname: availableHeight >= 112, - showTitle: availableHeight >= 72, - }; - } - - if (layout === "column") { - const itemHeight = Math.max(46, Math.min(72, availableHeight / count)); - return { - columns: 1, - horizontalScroll: false, - itemHeight, - itemWidth: availableWidth, - orientation: "horizontal", - showHostname: itemHeight >= 56 && availableWidth >= 150, - showTitle: availableWidth >= 92, - }; - } - - if (layout === "icons") { - const columns = Math.max(1, Math.min(count, Math.floor(availableWidth / 64))); - return { - columns, - horizontalScroll: false, - itemHeight: 56, - itemWidth: 56, - orientation: "icon", - showHostname: false, - showTitle: false, - }; - } - - const minimumWidth = layout === "gridHorizontal" ? 168 : layout === "grid" ? 104 : 64; - let columns = Math.max(1, Math.min(count, Math.floor(availableWidth / minimumWidth))); - if (layout === "adaptive") { - const targetAspectRatio = 1.7; - const idealColumns = Math.max( - 1, - Math.round(Math.sqrt((availableWidth * count) / (availableHeight * targetAspectRatio))), - ); - columns = Math.min(columns, idealColumns); - } - const rows = Math.max(1, Math.ceil(count / columns)); - const cellWidth = availableWidth / columns; - const cellHeight = availableHeight / rows; - let orientation: BookmarkOrientation = layout === "grid" ? "vertical" : "horizontal"; - - if (layout === "adaptive") { - if (cellHeight < 54 || cellWidth < 72) orientation = "icon"; - else if (cellWidth / cellHeight > 1.35 || cellHeight < 92) orientation = "horizontal"; - else orientation = "vertical"; - } - - return { - columns, - horizontalScroll: false, - itemHeight: Math.max(52, Math.min(128, cellHeight)), - itemWidth: layout === "adaptive" ? 144 : minimumWidth, - orientation, - showHostname: orientation !== "icon" && cellWidth >= 138 && cellHeight >= 64, - showTitle: orientation !== "icon" && cellWidth >= 88 && cellHeight >= 48, - }; -}; export default function BookmarksWidget({ options, @@ -212,15 +101,23 @@ export default function BookmarksWidget({ [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) => ( {options.title.length > 0 ? ( - + {options.title} ) : null} @@ -317,12 +214,12 @@ const BookmarkCard = ({ {showIcon ? : null} {showTitle ? ( - + {bookmark.name} ) : null} {showHostname ? ( - + {hostname} ) : null} @@ -342,17 +239,17 @@ const BookmarkCard = ({ {!iconOnly ? ( {showTitle ? ( - + {bookmark.name} ) : null} {showHostname ? ( - + {hostname} ) : null} {advanced && bookmark.description ? ( - + {bookmark.description} ) : null} @@ -413,7 +310,9 @@ const BookmarkCard = ({ styles={{ root: { background, - borderColor: active ? "var(--mantine-primary-color-filled)" : undefined, + 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", @@ -450,10 +349,14 @@ const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; i ); const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => { - if (active) return "var(--mantine-primary-color-light-hover)"; if (variant === "plain" || variant === "outline") return "transparent"; - if (variant === "filled") return "var(--mantine-color-default-hover)"; - return "var(--mantine-primary-color-light)"; + 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 => { diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index e70c84448..3d3909d84 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -1,534 +1,3 @@ "use client"; -import type { CSSProperties } from "react"; -import { useMemo } from "react"; -import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; -import { IconLink } from "@tabler/icons-react"; - -import { clientApi } from "@homarr/api/client"; -import { useRequiredBoard } from "@homarr/boards/context"; -import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { MaskedOrNormalImage } from "@homarr/ui"; - -import type { WidgetComponentProps } from "../definition"; -import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { getUsableWidgetQueryData } from "../common/query-state"; -import classes from "./bookmark.module.css"; - export { default } from "./bookmarks-widget"; - -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 function LegacyBookmarksWidget({ - options, - itemId, - width, - height, - displayMode, -}: WidgetComponentProps<"bookmarks">) { - const board = useRequiredBoard(); - const { ref: compactContentRef, width: compactContentWidth } = useElementSize(); - 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 ( - - {options.title.length > 0 && ( - - {options.title} - - )} - {displayMode === "advanced" && ( - - )} - {displayMode !== "advanced" && ( - -
- {options.layout === "grid" || options.layout === "gridHorizontal" ? ( - - ) : ( - - )} -
-
- )} -
- ); -} - -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 ( - - {data.map((app) => { - const href = getSafeAppHref(app.href); - return ( -
- - - {direction === "row" ? ( - - ) : ( - - )} - - -
- ); - })} -
- ); -}; - -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 ( - - - {data.map((app) => { - const href = getSafeAppHref(app.href); - return ( - - - - - - - {app.name} - - - {getBookmarkHostname(app.href)} - - {app.description && ( - - {app.description} - - )} - - - - - ); - })} - - - ); -}; - -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 ( - - {data.map((app) => { - const href = getSafeAppHref(app.href); - return ( - - - {itemDirection === "horizontal" ? ( - - ) : ( - - )} - - - ); - })} - - ); -}; - -const VerticalItem = ({ - app, - hideTitle, - hideIcon, - hideHostname, - hasIconColor, -}: { - app: BookmarkDisplayItem; - hideTitle: boolean; - hideIcon: boolean; - hideHostname: boolean; - hasIconColor: boolean; -}) => { - return ( - - {!hideTitle && ( - - {app.name} - - )} - {!hideIcon && ( - - )} - {!hideHostname && ( - - {getBookmarkHostname(app.href)} - - )} - - ); -}; - -const HorizontalItem = ({ - app, - hideTitle, - hideIcon, - hideHostname, - hasIconColor, -}: { - app: BookmarkDisplayItem; - hideTitle: boolean; - hideIcon: boolean; - hideHostname: boolean; - hasIconColor: boolean; -}) => { - return ( - - {!hideIcon && ( - - )} - {!(hideTitle && hideHostname) && ( - <> - - {!hideTitle && ( - - {app.name} - - )} - - {!hideHostname && ( - - {getBookmarkHostname(app.href)} - - )} - - - )} - - ); -}; - -interface BookmarkIconProps { - app: BookmarkDisplayItem; - hasColor: boolean; - className?: string; - style?: CSSProperties; -} - -const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) => - app.iconUrl ? ( - - ) : ( - - ); diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index fbd992c98..264218c9c 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -44,7 +44,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks withBorder: factory.switch({ defaultValue: false }), customUrls: factory.internal({ defaultValue: [] as string[] }), items: factory.sortableItemList({ - ItemComponent: ({ item, handle, removeItem, rootAttributes }) => { + ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => { const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href); return ( @@ -66,7 +66,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
- + diff --git a/packages/widgets/src/bookmarks/layout.spec.ts b/packages/widgets/src/bookmarks/layout.spec.ts index 1b13809db..8c459af23 100644 --- a/packages/widgets/src/bookmarks/layout.spec.ts +++ b/packages/widgets/src/bookmarks/layout.spec.ts @@ -1,54 +1,69 @@ import { describe, expect, test } from "vitest"; -import { - createCustomBookmark, - getAdvancedBookmarkColumns, - getBookmarkHostname, - getCompactBookmarkLayout, -} from "./component"; +import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item"; +import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout"; describe("bookmark display helpers", () => { - test("uses bounded advanced columns", () => { - expect(getAdvancedBookmarkColumns(900, 8)).toBe(3); - expect(getAdvancedBookmarkColumns(120, 8)).toBe(1); - expect(getAdvancedBookmarkColumns(900, 0)).toBe(1); + test("uses explicit width breakpoints for advanced columns", () => { + expect( + getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }), + ).toMatchObject({ + columns: 3, + orientation: "horizontal", + }); + expect( + getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }), + ).toMatchObject({ + columns: 1, + orientation: "horizontal", + }); }); - test("uses container geometry to keep compact cards legible", () => { - expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({ + test("selects an adaptive plan from width and height breakpoints", () => { + expect( + getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }), + ).toMatchObject({ columns: 2, - hideHostname: false, - hideTitle: false, - minimumItemSize: 48, + itemHeight: 128, + orientation: "horizontal", + showHostname: true, + showTitle: true, }); - - expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({ - columns: 4, - hideHostname: true, - hideTitle: true, - minimumItemSize: 96, + expect( + getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }), + ).toMatchObject({ + columns: 1, + orientation: "icon", + showHostname: false, + showTitle: false, }); }); - test("does not crash on malformed bookmark URLs", () => { - expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev"); - expect(getBookmarkHostname("not a URL")).toBeUndefined(); - expect(getBookmarkHostname(null)).toBeUndefined(); + test("keeps compact cards visible when icons are hidden", () => { + const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" }); + expect( + getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }), + ).toEqual({ + orientation: "horizontal", + showHostname: false, + showIcon: false, + showTitle: true, + }); }); test("creates standalone bookmarks without app records", () => { - expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({ - id: "custom-link:https://homarr.dev/docs", + expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({ + id: "url:https://homarr.dev/docs", name: "homarr.dev", description: null, href: "https://homarr.dev/docs", }); - expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({ - id: "custom-link:https://homarr.dev/docs", - name: "homarr.dev", - description: null, - href: "https://homarr.dev/docs", - }); - expect(createCustomBookmark("javascript:alert(1)")).toBeNull(); + expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs"); + expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined(); + }); + + test("splits URL lists without corrupting commas inside a URL", () => { + expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]); + expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]); }); }); diff --git a/packages/widgets/src/bookmarks/layout.ts b/packages/widgets/src/bookmarks/layout.ts new file mode 100644 index 000000000..ada81edef --- /dev/null +++ b/packages/widgets/src/bookmarks/layout.ts @@ -0,0 +1,265 @@ +import type { WidgetComponentProps } from "../definition"; + +export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; +export type BookmarkOrientation = "horizontal" | "vertical" | "icon"; + +export interface BookmarkDisplayPlan { + columns: number; + horizontalScroll: boolean; + itemHeight: number; + itemWidth: number; + orientation: BookmarkOrientation; + showHostname: boolean; + showTitle: boolean; +} + +export interface BookmarkCardDisplay { + orientation: BookmarkOrientation; + showHostname: boolean; + showIcon: boolean; + showTitle: boolean; +} + +export const getBookmarkCardDisplay = ({ + advanced, + hideHostname, + hideIcon, + hideTitle, + plan, +}: { + advanced: boolean; + hideHostname: boolean; + hideIcon: boolean; + hideTitle: boolean; + plan: BookmarkDisplayPlan; +}): BookmarkCardDisplay => { + let orientation = plan.orientation; + let showTitle = advanced || (!hideTitle && plan.showTitle); + if (!advanced && hideIcon && !hideTitle && !showTitle) { + orientation = "horizontal"; + showTitle = true; + } + const showHostname = advanced || (!hideHostname && plan.showHostname); + const showIcon = advanced || !hideIcon || (!showTitle && !showHostname); + + return { orientation, showHostname, showIcon, showTitle }; +}; + +interface BookmarkWidthBreakpoint { + adaptiveColumns: number; + adaptiveOrientation: BookmarkOrientation; + advancedColumns: number; + compactColumns: number; + gridColumns: number; + iconColumns: number; + minWidth: number; + rowItemWidth: number; + showHostname: boolean; + showTitle: boolean; +} + +const widthBreakpoints = [ + { + minWidth: 900, + adaptiveColumns: 4, + advancedColumns: 3, + compactColumns: 4, + gridColumns: 5, + iconColumns: 10, + rowItemWidth: 180, + adaptiveOrientation: "vertical", + showHostname: true, + showTitle: true, + }, + { + minWidth: 720, + adaptiveColumns: 3, + advancedColumns: 2, + compactColumns: 3, + gridColumns: 4, + iconColumns: 8, + rowItemWidth: 172, + adaptiveOrientation: "horizontal", + showHostname: true, + showTitle: true, + }, + { + minWidth: 480, + adaptiveColumns: 2, + advancedColumns: 2, + compactColumns: 2, + gridColumns: 3, + iconColumns: 6, + rowItemWidth: 160, + adaptiveOrientation: "horizontal", + showHostname: true, + showTitle: true, + }, + { + minWidth: 300, + adaptiveColumns: 2, + advancedColumns: 1, + compactColumns: 1, + gridColumns: 2, + iconColumns: 4, + rowItemWidth: 144, + adaptiveOrientation: "horizontal", + showHostname: false, + showTitle: true, + }, + { + minWidth: 0, + adaptiveColumns: 1, + advancedColumns: 1, + compactColumns: 1, + gridColumns: 1, + iconColumns: 2, + rowItemWidth: 112, + adaptiveOrientation: "icon", + showHostname: false, + showTitle: false, + }, +] as const satisfies readonly BookmarkWidthBreakpoint[]; + +interface BookmarkHeightBreakpoint { + columnItemHeight: number; + itemHeight: number; + minHeight: number; + orientation: BookmarkOrientation; + rowItemHeight: number; + showHostname: boolean; + showTitle: boolean; +} + +const heightBreakpoints = [ + { + minHeight: 300, + columnItemHeight: 72, + itemHeight: 128, + rowItemHeight: 128, + orientation: "vertical", + showHostname: true, + showTitle: true, + }, + { + minHeight: 180, + columnItemHeight: 64, + itemHeight: 128, + rowItemHeight: 112, + orientation: "horizontal", + showHostname: true, + showTitle: true, + }, + { + minHeight: 110, + columnItemHeight: 56, + itemHeight: 80, + rowItemHeight: 80, + orientation: "horizontal", + showHostname: false, + showTitle: true, + }, + { + minHeight: 0, + columnItemHeight: 48, + itemHeight: 56, + rowItemHeight: 56, + orientation: "icon", + showHostname: false, + showTitle: false, + }, +] as const satisfies readonly BookmarkHeightBreakpoint[]; + +export const getBookmarkDisplayPlan = ({ + advanced, + height, + itemCount, + layout, + width, +}: { + advanced: boolean; + height: number; + itemCount: number; + layout: BookmarkLayout; + width: number; +}): BookmarkDisplayPlan => { + const count = Math.max(1, itemCount); + const normalizedWidth = Math.max(0, width); + const normalizedHeight = Math.max(0, height); + const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth); + const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight); + if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes"); + + if (advanced) { + return { + columns: Math.min(count, widthSettings.advancedColumns), + horizontalScroll: false, + itemHeight: 104, + itemWidth: 260, + orientation: "horizontal", + showHostname: true, + showTitle: true, + }; + } + + if (layout === "row") { + let orientation: BookmarkOrientation = "vertical"; + if (!heightSettings.showTitle) orientation = "icon"; + return { + columns: count, + horizontalScroll: true, + itemHeight: heightSettings.rowItemHeight, + itemWidth: widthSettings.rowItemWidth, + orientation, + showHostname: widthSettings.showHostname && heightSettings.showHostname, + showTitle: widthSettings.showTitle && heightSettings.showTitle, + }; + } + + if (layout === "column") { + return { + columns: 1, + horizontalScroll: false, + itemHeight: heightSettings.columnItemHeight, + itemWidth: width, + orientation: "horizontal", + showHostname: widthSettings.showHostname && heightSettings.showHostname, + showTitle: widthSettings.showTitle && heightSettings.showTitle, + }; + } + + if (layout === "icons") { + return { + columns: Math.min(count, widthSettings.iconColumns), + horizontalScroll: false, + itemHeight: 56, + itemWidth: 56, + orientation: "icon", + showHostname: false, + showTitle: false, + }; + } + + let columns: number = widthSettings.compactColumns; + let orientation: BookmarkOrientation = "horizontal"; + if (layout === "grid") { + columns = widthSettings.gridColumns; + orientation = "vertical"; + } + if (layout === "adaptive") { + columns = widthSettings.adaptiveColumns; + orientation = widthSettings.adaptiveOrientation; + if (heightSettings.orientation === "icon") orientation = "icon"; + if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal"; + } + + return { + columns: Math.min(count, columns), + horizontalScroll: false, + itemHeight: heightSettings.itemHeight, + itemWidth: widthSettings.rowItemWidth, + orientation, + showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname, + showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle, + }; +}; diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 42572d598..2d39a554d 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -34,6 +34,7 @@ export interface SortableItemListInput { AddButton: (props: { addItem: (item: TItem) => void; + migrateItems: (items: TItem[], optionsPatch: Record) => void; removeItem: (value: TOptionValue) => void; values: TOptionValue[]; initialOptions: Record; @@ -41,6 +42,7 @@ export interface SortableItemListInput void; + removeLabel: string; rootAttributes: DraggableAttributes; handle: React.ReactNode; }) => React.ReactNode;