Compare commits

...
Author SHA1 Message Date
ajnart 620c13db9c fix: honor bookmark column density 2026-09-07 10:46:27 +02:00
ajnart 58f4436485 fix: render dense bookmark grids as icons 2026-09-07 10:44:01 +02:00
ajnart 81daa87b9e fix: improve bookmark widget density 2026-09-07 10:38:13 +02:00
4 changed files with 165 additions and 29 deletions

No files matched your search

@@ -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
+3 -2
View File
@@ -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",
},
@@ -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 <WidgetQueryLoadingState />;
const isTight = responsiveHeight < 120;
return (
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
<Stack h="100%" mih={0} gap={isTight ? 6 : "sm"} p={isTight ? 6 : "sm"}>
{options.title.length > 0 ? (
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
{options.title}
@@ -151,14 +165,18 @@ export default function BookmarksWidget({
</Stack>
</Center>
) : (
<ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
<Box miw="100%" mih="100%" pb={2}>
<ScrollArea
scrollbars={plan.horizontalScroll ? "x" : "y"}
style={{ flex: 1, minHeight: 0 }}
styles={{ content: { height: "100%" } }}
>
<Box miw="100%" h="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
<Group gap={plan.itemGap} wrap="nowrap" h="100%" align="center">
{cards}
</Group>
) : options.grow ? (
<Grid grow columns={plan.columns} gap={options.spacing}>
<Grid grow columns={plan.columns} gap={plan.itemGap}>
{cards.map((card) => (
<Grid.Col key={card.key} span={1}>
{card}
@@ -166,7 +184,7 @@ export default function BookmarksWidget({
))}
</Grid>
) : (
<SimpleGrid cols={plan.columns} spacing={options.spacing} verticalSpacing={options.spacing}>
<SimpleGrid cols={plan.columns} spacing={plan.itemGap} verticalSpacing={plan.itemGap}>
{cards}
</SimpleGrid>
)}
@@ -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 ? (
<BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={advanced ? 42 : iconOnly ? 32 : 30} />
) : null}
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={avatarSize} /> : null}
{!iconOnly ? (
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
{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;
+110 -14
View File
@@ -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,28 +228,41 @@ 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,
showTitle: widthSettings.showTitle && heightSettings.showTitle,
showHostname: widthSettings.showHostname && densitySettings.showHostname,
showTitle: widthSettings.showTitle && densitySettings.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,69 @@ 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;
if (!showHostname && !showTitle) orientation = "icon";
}
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);
};