Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620c13db9c | ||
|
|
58f4436485 | ||
|
|
81daa87b9e | ||
|
|
6db26a0d99 | ||
|
|
bbcbd031fe | ||
|
|
7024a57f56 | ||
|
|
5121356113 | ||
|
|
4f2ab59345 | ||
|
|
73bf68c9a8 |
No files matched your search
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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>.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
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
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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>.
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
Pick which buttons appear, put them on the left, center or right, and change their order.
|
||||
|
||||
### Create Homarr content from container labels
|
||||

|
||||
|
||||
`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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
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.
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user