diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index db2dc85a5..a8808eb6f 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -15,6 +15,10 @@ import { bookmarksWidget } from "."; The Bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. URLs do not require an App record; Homarr uses the site's favicon when available. Choose adaptive, compact-grid, or other layouts from the widget configuration. +Adaptive, vertical, and grid layouts keep larger cards when the configured bookmarks fit, then reduce card height and +spacing to show as many bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with +narrower cards in one-row widgets. Horizontal, compact-grid, and icon-only layouts keep their gaps tight. Compact grid +also preserves its configured columns when the dashboard is scaled. ### Screenshots diff --git a/apps/docs/docs/widgets/bookmarks/index.ts b/apps/docs/docs/widgets/bookmarks/index.ts index b335a70fd..bfc971457 100644 --- a/apps/docs/docs/widgets/bookmarks/index.ts +++ b/apps/docs/docs/widgets/bookmarks/index.ts @@ -16,7 +16,8 @@ export const bookmarksWidget: WidgetDefinition = { }, { name: "Layout", - description: "How bookmarks use the available widget space.", + description: + "How bookmarks use the available widget space. Layouts stay compact and reduce card size when needed.", values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] }, defaultValue: "Adaptive", }, @@ -28,7 +29,7 @@ export const bookmarksWidget: WidgetDefinition = { }, { name: "Card spacing", - description: "Space between bookmark cards.", + description: "Space between bookmark cards. Horizontal, compact-grid, and icon-only layouts cap larger gaps.", values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] }, defaultValue: "Extra small", }, diff --git a/packages/widgets/src/bookmarks/bookmarks-widget.tsx b/packages/widgets/src/bookmarks/bookmarks-widget.tsx index 5838af748..968b47303 100644 --- a/packages/widgets/src/bookmarks/bookmarks-widget.tsx +++ b/packages/widgets/src/bookmarks/bookmarks-widget.tsx @@ -41,6 +41,7 @@ export default function BookmarksWidget({ width, height, displayMode, + displayScale = 1, }: WidgetComponentProps<"bookmarks">) { const t = useI18n("widget.bookmarks"); const board = useRequiredBoard(); @@ -66,6 +67,16 @@ export default function BookmarksWidget({ }); const data = [...configuredItems, ...legacyItems]; + let responsiveWidth = width; + let responsiveHeight = height; + if (!advanced && Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + responsiveHeight *= displayScale; + } + const contentHeight = Math.max(0, responsiveHeight - (options.title.length > 0 ? 36 : 0)); + let layoutWidth = responsiveWidth; + if (options.layout === "gridHorizontal") layoutWidth = width; + useRegisterSpotlightContextResults( `bookmark-${itemId}`, data.flatMap((bookmark) => { @@ -95,12 +106,13 @@ export default function BookmarksWidget({ () => getBookmarkDisplayPlan({ advanced, - height: options.title.length > 0 ? height - 36 : height, + gap: bookmarkSpacingPixels[options.spacing], + height: contentHeight, itemCount: data.length, layout: options.layout, - width, + width: layoutWidth, }), - [advanced, data.length, height, options.layout, options.title.length, width], + [advanced, contentHeight, data.length, layoutWidth, options.layout, options.spacing], ); const cardDisplay = getBookmarkCardDisplay({ @@ -131,8 +143,10 @@ export default function BookmarksWidget({ if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return ; + const isTight = responsiveHeight < 120; + return ( - + {options.title.length > 0 ? ( {options.title} @@ -151,14 +165,18 @@ export default function BookmarksWidget({ ) : ( - - + + {plan.horizontalScroll ? ( - + {cards} ) : options.grow ? ( - + {cards.map((card) => ( {card} @@ -166,7 +184,7 @@ export default function BookmarksWidget({ ))} ) : ( - + {cards} )} @@ -213,6 +231,17 @@ const BookmarkCard = ({ const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href); const iconOnly = orientation === "icon" || (!showTitle && !showHostname); const background = getBookmarkBackground(variant, active); + const isUltraDense = height <= 32; + const isDense = height <= 48; + let avatarSize = 30; + if (isDense) avatarSize = 28; + if (isUltraDense) avatarSize = 20; + if (iconOnly && !isDense) avatarSize = 32; + if (advanced) avatarSize = 42; + let padding: number | "xs" | "md" = "xs"; + if (isDense || iconOnly) padding = 6; + if (isUltraDense) padding = 4; + if (advanced) padding = "md"; const content = orientation === "vertical" && !advanced ? ( @@ -238,9 +267,7 @@ const BookmarkCard = ({ align="center" flex={1} > - {showIcon ? ( - - ) : null} + {showIcon ? : null} {!iconOnly ? ( {showTitle ? ( @@ -304,7 +331,7 @@ const BookmarkCard = ({ aria-label={bookmark.name} radius={radius} withBorder={withBorder || variant === "outline"} - p={iconOnly ? 6 : advanced ? "md" : "xs"} + p={padding} h={height} w={width} miw={width} @@ -371,3 +398,11 @@ const getBookmarkHostname = (href: string): string | undefined => { return undefined; } }; + +const bookmarkSpacingPixels = { + xs: 10, + sm: 12, + md: 16, + lg: 20, + xl: 32, +} as const; diff --git a/packages/widgets/src/bookmarks/layout.ts b/packages/widgets/src/bookmarks/layout.ts index 0c0d7b32e..ba84d0001 100644 --- a/packages/widgets/src/bookmarks/layout.ts +++ b/packages/widgets/src/bookmarks/layout.ts @@ -6,6 +6,7 @@ export type BookmarkOrientation = "horizontal" | "vertical" | "icon"; export interface BookmarkDisplayPlan { columns: number; horizontalScroll: boolean; + itemGap: number; itemHeight: number; itemWidth: number; orientation: BookmarkOrientation; @@ -107,6 +108,18 @@ const widthBreakpoints = [ showHostname: false, showTitle: true, }, + { + minWidth: 180, + adaptiveColumns: 1, + advancedColumns: 1, + compactColumns: 1, + gridColumns: 1, + iconColumns: 3, + rowItemWidth: 136, + adaptiveOrientation: "horizontal", + showHostname: false, + showTitle: true, + }, { minWidth: 0, adaptiveColumns: 1, @@ -160,24 +173,35 @@ const heightBreakpoints = [ showTitle: true, }, { - minHeight: 0, + minHeight: 60, columnItemHeight: 48, - itemHeight: 56, + itemHeight: 48, rowItemHeight: 56, orientation: "icon", showHostname: false, showTitle: false, }, + { + minHeight: 0, + columnItemHeight: 32, + itemHeight: 32, + rowItemHeight: 40, + orientation: "icon", + showHostname: false, + showTitle: false, + }, ] as const satisfies readonly BookmarkHeightBreakpoint[]; export const getBookmarkDisplayPlan = ({ advanced, + gap = 0, height, itemCount, layout, width, }: { advanced: boolean; + gap?: number; height: number; itemCount: number; layout: BookmarkLayout; @@ -194,6 +218,7 @@ export const getBookmarkDisplayPlan = ({ return { columns: Math.min(count, widthSettings.advancedColumns), horizontalScroll: false, + itemGap: gap, itemHeight: 104, itemWidth: 260, orientation: "horizontal", @@ -203,27 +228,40 @@ export const getBookmarkDisplayPlan = ({ } if (layout === "row") { - let orientation: BookmarkOrientation = "vertical"; - if (!heightSettings.showTitle) orientation = "icon"; + let orientation: BookmarkOrientation = "horizontal"; + if (!widthSettings.showTitle) orientation = "icon"; + let itemWidth: number = widthSettings.rowItemWidth; + if (heightSettings.rowItemHeight <= 56) itemWidth = 112; + if (heightSettings.rowItemHeight <= 40) itemWidth = 96; return { columns: count, horizontalScroll: true, - itemHeight: heightSettings.rowItemHeight, - itemWidth: widthSettings.rowItemWidth, + itemGap: Math.min(gap, 8), + itemHeight: Math.min(heightSettings.rowItemHeight, 64), + itemWidth, orientation, showHostname: widthSettings.showHostname && heightSettings.showHostname, - showTitle: widthSettings.showTitle && heightSettings.showTitle, + showTitle: widthSettings.showTitle, }; } if (layout === "column") { + const densitySettings = getBookmarkDensitySettings({ + columns: 1, + gap, + height: normalizedHeight, + heightProperty: "columnItemHeight", + itemCount: count, + preferredSettings: heightSettings, + }); return { columns: 1, horizontalScroll: false, - itemHeight: heightSettings.columnItemHeight, + itemGap: getBookmarkDensityGap(gap, densitySettings), + itemHeight: densitySettings.columnItemHeight, itemWidth: width, orientation: "horizontal", - showHostname: widthSettings.showHostname && heightSettings.showHostname, + showHostname: widthSettings.showHostname && densitySettings.showHostname, showTitle: widthSettings.showTitle && heightSettings.showTitle, }; } @@ -232,6 +270,7 @@ export const getBookmarkDisplayPlan = ({ return { columns: Math.min(count, widthSettings.iconColumns), horizontalScroll: false, + itemGap: Math.min(gap, 8), itemHeight: 56, itemWidth: 56, orientation: "icon", @@ -244,6 +283,7 @@ export const getBookmarkDisplayPlan = ({ return { columns: Math.min(count, widthSettings.compactColumns), horizontalScroll: false, + itemGap: Math.min(gap, 8), itemHeight: 56, itemWidth: widthSettings.rowItemWidth, orientation: "horizontal", @@ -265,13 +305,68 @@ export const getBookmarkDisplayPlan = ({ if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal"; } + const visibleColumns = Math.min(count, columns); + const densitySettings = getBookmarkDensitySettings({ + columns: visibleColumns, + gap, + height: normalizedHeight, + heightProperty: "itemHeight", + itemCount: count, + preferredSettings: heightSettings, + }); + if (layout === "adaptive" && densitySettings.itemHeight < 80 && orientation === "vertical") { + orientation = "horizontal"; + } + + let showHostname = + orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname && densitySettings.showHostname; + let showTitle = orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle; + if (layout === "grid") { + showHostname = showHostname && densitySettings.showHostname; + showTitle = showTitle && densitySettings.showTitle; + } + return { - columns: Math.min(count, columns), + columns: visibleColumns, horizontalScroll: false, - itemHeight: heightSettings.itemHeight, + itemGap: getBookmarkDensityGap(gap, densitySettings), + itemHeight: densitySettings.itemHeight, itemWidth: widthSettings.rowItemWidth, orientation, - showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname, - showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle, + showHostname, + showTitle, }; }; + +type BookmarkHeightProperty = "columnItemHeight" | "itemHeight"; +type BookmarkHeightSettings = (typeof heightBreakpoints)[number]; + +const getBookmarkDensitySettings = ({ + columns, + gap, + height, + heightProperty, + itemCount, + preferredSettings, +}: { + columns: number; + gap: number; + height: number; + heightProperty: BookmarkHeightProperty; + itemCount: number; + preferredSettings: BookmarkHeightSettings; +}): BookmarkHeightSettings => { + const rowCount = Math.ceil(itemCount / Math.max(1, columns)); + const totalGap = Math.max(0, rowCount - 1) * Math.max(0, gap); + const preferredIndex = heightBreakpoints.indexOf(preferredSettings); + const matchingSettings = heightBreakpoints + .slice(preferredIndex) + .find((settings) => rowCount * settings[heightProperty] + totalGap <= height); + + return matchingSettings ?? heightBreakpoints[4]; +}; + +const getBookmarkDensityGap = (gap: number, settings: BookmarkHeightSettings) => { + if (settings.itemHeight > 32 && settings.columnItemHeight > 32) return gap; + return Math.min(gap, 4); +};