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
ajnart 6db26a0d99 docs: restore release article hierarchy 2026-09-06 20:41:55 +02:00
ajnart bbcbd031fe docs: clarify Homarr provider terms 2026-09-06 20:37:52 +02:00
ajnart 7024a57f56 docs: tighten Homarr 2.0 release article 2026-09-06 19:57:44 +02:00
Thomas 5121356113 Merge pull request #6794 from homarr-labs/feat/refocus-homarr-v2-article
docs: refocus Homarr 2.0 article
2026-09-06 19:20:05 +02:00
ajnart 4f2ab59345 docs: refocus Homarr 2.0 article 2026-09-06 19:19:23 +02:00
Thomas 73bf68c9a8 Merge pull request #6792 from homarr-labs/feat/v2-release-client-ids
fix(ci): use GitHub App client IDs
2026-09-06 18:05:28 +02:00
5 changed files with 254 additions and 175 deletions

No files matched your search

+89 -146
View File
@@ -1,17 +1,19 @@
---
title: "Homarr 2.0.0: our biggest upgrade yet"
authors:
- ajnart
- manuel-rw
- tagashi
- meierschlumpf
tags: [homarr, update, version, assistant, custom widgets, dashboards, docker]
draft: false
---
Homarr 2.0 is our biggest upgrade so far. It keeps the same job: put your apps, services, and status on one dashboard.
# Homarr 2.0.0 - Workshop, Custom Widgets and a rebuilt board
We rebuilt the parts that had become difficult to extend: boards, Custom Widgets, Docker discovery, Assistant, and MCP.
First, thank you.
Many of you used the v2 demo when it was still rough, found bugs and came back to test the next build. Others translated Homarr, answered questions, contributed code or simply kept using it. We would not be shipping this without you.
We are also planning a small batch of **free, limited-edition Homarr swag** for Workshop contributors and a few randomly picked community members. It is not for sale and it is not for profit. No Homarr merch empire, sorry.
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
@@ -19,201 +21,142 @@ We rebuilt the parts that had become difficult to extend: boards, Custom Widgets
:::warning Back up before upgrading
Create a [Homarr backup](/docs/management/backup) and copy your application data before starting 2.0. Do not test a prerelease image on the only copy of your production volume. Backups are boring. Restores are worse.
Create a [Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume. Backups are boring. Restores are worse.
:::
## Boards
## Custom Widgets v2 and Workshop
### Brand new drag and drop
### Build almost any widget
We re-thought drag and drop from the ground up and switched to [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr calculates a preview, checks collisions, and commits only a valid layout.
**This is the big one.** We think it is the most important Homarr update we have made.
Invalid or cancelled moves roll back. The same engine handles the main grid, rails, and nested Containers with pointer, touch, or keyboard input.
Custom Widgets v2 lets you build widgets from JSX, API requests, actions and typed settings. Write one yourself or describe it to Assistant, then test it in the workbench. We think this is enough to make almost any dashboard widget—even that oddly specific component only three people need. Those three people no longer have to wait for us.
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### Goodbye Groups and sections, welcome Containers
Groups and dynamic sections overlapped. Containers replace both with one model for apps, widgets, and nested Containers. They can be moved, resized, collapsed, scrolled, labeled, or configured to open their apps together.
### Move multiple items at once
Hold <kbd>Ctrl</kbd> on Windows or Linux, or <kbd>Command</kbd> on macOS, to select several items. Copy, delete, or move the selection to the main grid, a rail, or a Container. The move is saved only when every item fits.
![Three selected board items with the batch Move menu offering the main canvas and right rail](./img/batch-move.webp)
### Responsive layouts for the same board
Each board has protected Base and Mobile layouts, with optional breakpoints between them. Homarr selects the highest matching breakpoint and scales the complete layout instead of changing widget proportions.
**Reset from Base** generates a Mobile starting point without duplicating the board. Desktop layouts can reserve one to three columns as rails; Mobile places those items back in the main flow.
![The responsive layout editor beside the same Homarr board rendered at a mobile viewport](./img/responsive-layouts.webp)
### A visual board switcher
The board switcher is now a searchable gallery with keyboard navigation. Open it from the header or press <kbd>Shift</kbd> + <kbd>C</kbd>.
![A visual board card inside the searchable Homarr board switcher](./img/board-switcher.webp)
### Configure your header
The Header studio arranges built-in controls across left, center, and right areas. Logo, search, board, Assistant, Docker, editing, settings, theme, and account controls are available. A recovery control remains when the normal header is hidden.
![The Header studio showing left, center, and right zones plus available controls](./img/header-studio.webp)
### Add content without leaving the board
The board menu creates widgets, apps, integrations, and Containers without opening a management page. Compatible integrations stay in the same flow, apps have Quick Add, and Workshop sits beside Custom Widgets.
![The board's Add content menu offering a widget, app, integration, or Container](./img/add-content-menu.webp)
Small destructive actions can use a second click for confirmation; higher-risk actions keep a full dialog.
Read the [board guide](/docs/management/boards) for layout rules, rails, Containers, and keyboard controls.
## Search, Assistant, and MCP
### One command menu
Press <kbd>Cmd</kbd>/<kbd>Ctrl</kbd> + <kbd>K</kbd> to search Homarr resources, pages, settings, users, groups, and commands. Assistant, media, and web providers can add results. Homarr results rank first, and create actions follow your permissions.
![The Homarr 2.0 command menu opening and returning mixed results for an Assistant query](./img/command-menu.gif)
### Assistant inside Homarr
Open Assistant from a board or the user menu with <kbd>Shift</kbd> + <kbd>A</kbd>. <kbd>Cmd</kbd>/<kbd>Ctrl</kbd> + <kbd>/</kbd> opens Assistant search. It reads live Homarr data and uses tools for boards, apps, integrations, widgets, Docker, and supported services.
Tool calls use the signed-in user's permissions. Read-only calls can run automatically; changes require approval by default. Provider credentials stay encrypted on the server. Providers, models, attachments, mentions, sources, usage, and private history are supported. Workshop also provides a community-funded provider.
![The Homarr Assistant expanded over a live dashboard with health, exploration, media, and board actions](./img/assistant-control-surface.webp)
### Connect external clients with MCP
Homarr now exposes a stateless MCP server at `/api/mcp`. It implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients. The older `/api/mcp/mcp` path remains as an alias; the removed HTTP+SSE routes return `410 Gone`.
Authenticate with an API key or OAuth 2.1 with PKCE. Tools cover boards, apps, integrations, Docker, Kubernetes, media, downloads, DNS, and Custom Widget authoring. The catalog returned by `tools/list` follows the current release and the authenticated user's permissions; confirmations still apply to changes.
![Homarr's AI and MCP management page with authentication and Streamable HTTP client configuration](./img/mcp-management.webp)
See [Assistant setup](/docs/management/assistant) and the [MCP guide](/docs/management/mcp).
## Custom Widgets v2
### A proper workbench
We rebuilt the editor around the `homarr-custom-widget-v2` schema. API sources, requests, options, JSX, validation, and preview now share one workspace. Explicit preview generation prevents stale requests from replacing newer work, with three sizes and Live, Loading, Empty, and Error states.
This was partly inspired by [Google's upcoming Create My Widget feature](https://blog.google/products-and-platforms/platforms/android/gemini-intelligence/): describe a widget in normal language and let AI make it. Coding knowledge is optional. Knowing what the API does still helps; AI has not fixed bad documentation. Yet.
![The Custom Widget v2 workbench after a live exchange-rate request successfully renders its preview](./img/custom-widget-workbench.webp)
### Server-side requests and actions
A `homarr-custom-widget-v2` definition can use several fixed-origin API sources. Credentials are encrypted separately and stay out of exports and Workshop submissions. Actions support `GET`, `POST`, `PUT`, `PATCH` and `DELETE`; requests have host checks, timeouts, size limits, permissions and rate limits. Custom Widgets remain **Beta** in 2.0.
A widget can use several fixed-origin API sources with separately encrypted credentials. Load requests support caching and manual or automatic refreshes. `ActionButton` and `ToggleSwitch` can run GET, POST, PUT, PATCH, and DELETE actions with confirmation, feedback, rollback, and query invalidation.
### Share widgets and Custom CSS
![A Custom Widget v2 request configured as a permission-checked POST action with body and refresh controls](./img/custom-widget-post-action.webp)
Widgets can expose saved controls for text, numbers, toggles, selections, sliders, dates, colors, icons, URLs, time zones, and JSON. Values feed request paths, query parameters, and nested JSON bodies, while select choices can come from another request.
Host validation, redirect checks, timeouts, response limits, request budgets, permissions, and rate limits constrain requests. Custom Widgets remain **Beta** in 2.0.
### Share through Workshop
Workshop lets you publish, install, update, vote on, report, and share Custom Widgets and CSS. Submission screenshots now open in a keyboard-accessible viewer with thumbnails and zoom.
The workbench can also generate a credential-free prompt for AI-assisted authoring. Deployment URLs and credentials stay in your Homarr instance.
**Workshop is the other half.** Publish and install Custom Widgets, then update, vote on, comment on and report submissions. Workshop is not only for widgets: you can share your **Custom CSS** there too. Deployment URLs and credentials stay on your Homarr instance.
![The Community Workshop catalog inside Custom Widget management](./img/community-workshop.webp)
Read the [Custom Widgets guide](/docs/management/custom-widgets) and [request security reference](/docs/management/custom-widgets/requests-and-security).
:::warning Workshop is moderated
## Widgets
AI makes widgets easy to produce. It does not make the first thing it coughs up worth publishing. **Do not dump untested slop into Workshop.** Test it, add a useful screenshot and explain its setup. Workshop is moderated.
### Advanced views without larger tiles
:::
Supported widgets expand by Shift-hover, keyboard, or context menu. Preview the advanced view or pin it while the board becomes inactive. Widgets opt in explicitly, so compact tiles remain the default elsewhere.
### The ultimate Homarr update
![The Download Client advanced view with transfer totals, filters, status, and per-item detail](./img/advanced-downloads.webp)
We have called this the **"ultimate Homarr update"**. Not because work stops, but because every new service or widget idea no longer needs a full Homarr release; the community can build and share it instead.
### Settings with a live preview
## Board editing, rebuilt
Widget settings now combine live preview, grouped options, and in-place app or integration editing. Weather and Clock were redesigned; Air Quality, Countdown, and Timer were added.
### Drag and drop
Notebook gained collapsible sections and table controls for rows, columns, headers, cell colors, merging, and splitting. Health Monitoring now renders CPU usage, CPU temperature, memory, and GPU utilization as threshold-colored gauges.
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
## Setup, sign-in, and Docker
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### One onboarding flow
### Goodbye Groups and sections, bonjour Containers
The six-stage setup studio discovers Docker-compatible services, prepares app and integration drafts, and creates the first board in one transaction.
Groups and dynamic sections did nearly the same job, so **Containers replace both**. They hold apps, widgets and other Containers. Hold <kbd>Ctrl</kbd> or <kbd>Command</kbd> to move several items at once.
### One board, several layouts
Boards also have Base and Mobile layouts, plus optional breakpoints. **Reset from Base** creates a Mobile starting point without duplicating the board.
## Homarr Assistant
### Work with live Homarr data
Assistant reads live Homarr data and has tools for boards, apps, integrations, widgets, Docker and supported services. It can answer questions, make approved changes and build Custom Widgets. It follows your permissions: reads can run automatically, while changes require approval by default.
![The Homarr Assistant expanded over a live dashboard with health, exploration, media, and board actions](./img/assistant-control-surface.webp)
Open it with <kbd>Shift</kbd> + <kbd>A</kbd>. <kbd>Cmd</kbd>/<kbd>Ctrl</kbd> + <kbd>/</kbd> searches it.
### The Homarr provider
:::note
Assistant ships with a **"Homarr provider"**. It is free, will never offer paid plans, and is paid for by [Ajnart](https://github.com/ajnart) using the ad revenue from this website.
It only uses zero-data-retention (ZDR) providers. [The provider is open source](https://github.com/homarr-labs/homarr/blob/release/v2/apps/workshop/homarr_provider.go), and neither prompts nor responses are logged. We are not selling your data. Pinky promise.
:::
### External assistants through MCP
External assistants can use the same permission-checked tools through `/api/mcp`, with an API key or OAuth 2.1 with PKCE.
## The rest (there is a lot)
### Brand-new onboarding
A six-stage setup studio handles the first admin, core settings, Docker discovery, apps, integrations and the first board.
![The Homarr 2.0 setup studio on its Essentials step, with all six setup stages shown](./img/onboarding-studio.webp)
### Branded sign-in and better external authentication
### Board switcher
Login pages use the name, logo, background, greeting, colors, and corner style configured for the instance.
Boards now live in a searchable gallery with keyboard navigation. Open it from the header or press <kbd>Shift</kbd> + <kbd>C</kbd>.
![The upgraded Homarr login page with a branded backdrop and centered account card](./img/new-login.webp)
![A visual board card inside the searchable Homarr board switcher](./img/board-switcher.webp)
LDAP can provision a Homarr user on first sign-in. OIDC uses its Auth.js adapter, can link a verified email to an existing credentials account when enabled, and can synchronize external membership into existing Homarr groups. Exact behavior depends on the provider.
### Command menu and keybinds
### Multiple Docker and Podman endpoints
<kbd>Cmd</kbd>/<kbd>Ctrl</kbd> + <kbd>K</kbd> finds resources, pages, settings, users, groups and commands. More
keybinds cover Assistant, editing, selection and advanced widget views.
`DOCKER_ENDPOINTS` defines Docker or Podman hosts with separate transports and capabilities. Healthy endpoint inventory remains available when another endpoint fails. Assisted setup compares containers with existing resources, suggests apps and integrations, and derives likely URLs.
### Customizable header
![Assisted Docker setup listing discovered services that can become apps or integrations](./img/docker-assisted-setup.webp)
Pick which buttons appear, put them on the left, center or right, and change their order.
### Create Homarr content from container labels
![The Header studio showing left, center, and right zones plus available controls](./img/header-studio.webp)
`homarr.name` and `homarr.href` identify a service. Optional `homarr.*` labels add integrations, place content in Containers, select widgets, set icons and health checks, keep stable IDs, and target a board.
### Instance branding
When `homarr.name` is absent, Homarr can use Homepage's `name`, `href`, `group`, `icon`, and `description` labels. Both `homepage.name` and `homepage.href` are required, and Homarr does not combine partial metadata from the two formats.
See the [Docker label reference](/docs/integrations/docker-labels) for the exact fields and required combinations.
## Permissions and appearance
### Permissions you can inspect
Group permissions now use a matrix with Viewer, Editor, and Admin presets. Access levels and create permissions are separate, with inherited and effective permissions shown before saving.
![The Editor permission preset showing explicit board, app, integration, search, media, and administration scopes](./img/permission-matrix.webp)
App, integration, and home-stat data are filtered on the server. Management navigation, API procedures, Assistant, and MCP use the same permission model.
### Instance branding and global CSS
Server branding now covers the application name, logo, favicon, colors, board accent behavior, and corner styles.
Change the name, logo, favicon, colors, corner styles and login page. Global CSS applies instance-wide; board CSS can override it. Only give that power to admins you trust. CSS remains CSS.
![Instance branding controls for application identity, palette, corner style, and global CSS](./img/instance-branding.webp)
Authentication pages add background, overlay, greeting, and visibility controls. Global CSS applies across the instance; board CSS is more specific and wins later in the cascade.
### Widgets
![The global CSS editor, authentication appearance controls, and current branding summary](./img/global-css-and-auth.webp)
Add content without leaving the board. Settings have live previews, and supported widgets have advanced views. Weather and Clock were redesigned; Air Quality, Countdown and Timer are new.
Global CSS is not a security sandbox. Only trusted administrators should control it. See [Styling](/docs/advanced/styling) and [Server settings](/docs/management/settings).
### Docker and Podman
## Less waiting and duplicate work
`DOCKER_ENDPOINTS` supports several hosts. Assisted setup and container labels can create apps, integrations, Containers, widgets and health checks.
Board startup now runs independent session, layout, and widget-prefetch work in parallel. Spotlight, editors, media, and other heavy surfaces load only when opened. Drag previews and Custom Widget analysis update without re-rendering the complete page.
![Assisted Docker setup listing discovered services that can become apps or integrations](./img/docker-assisted-setup.webp)
Identical integration requests share in-flight work. Bounded in-process and Redis-backed caches coordinate responses and refresh locks across processes, while stale data can remain visible during a temporary upstream failure. Eligible public widget data can also return from the session cache while it refreshes.
Homepage-compatible labels work too.
For release hardening, we also applied the current Auth.js, `protobufjs`, and production dependency security patches.
### Permissions
Groups use a new Viewer, Editor and Admin matrix. The API, Assistant and MCP follow the same rules. LDAP can create users on first sign-in; OIDC can link verified accounts and synchronize groups.
### Performance
Board startup work runs in parallel, heavy screens load when opened, and identical integration requests share work instead of doing it twice.
## Before upgrading
- Keep a backup and use the final release image, not a prerelease tag.
- Legacy Custom JSX definitions enter an explicit migration state. Homarr keeps encrypted credentials and an archived copy until the v2 definition validates.
- Legacy Custom JSX definitions enter a migration state. Homarr keeps encrypted credentials and an archived copy until the v2 definition validates.
- Mobile layouts place rail content in the main flow instead of displaying rails.
- Custom Widgets remain Beta.
- Assistant and MCP never bypass user, API-key, integration, or Docker endpoint permissions.
- Assistant and MCP do not bypass permissions.
## Links
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp), and [Docker labels](/docs/integrations/docker-labels).
{/* Editorial release gate: set draft to false, update the dated path if needed, replace the branch comparison with the v2.0.0 tag, and verify the production image and migration notes before publishing. */}
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
@@ -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);
};