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
ajnart dea1de5fee fix(ci): use GitHub App client IDs 2026-09-06 17:57:35 +02:00
Thomas 2bd538e685 Merge pull request #6791 from homarr-labs/feat/publish-homarr-v2-blog
docs: publish Homarr 2.0 article
2026-09-06 17:52:05 +02:00
ajnart f0c39ea0d2 docs: publish Homarr 2.0 article 2026-09-06 17:51:36 +02:00
Thomas 8a0cf15bf7 docs: finalize Homarr 2.0 release article (#6790) 2026-09-06 17:48:08 +02:00
Thomas f1c898efe2 docs: add Homarr 2.0 release article (#6789) 2026-09-06 15:39:54 +02:00
Thomas 724dee55c5 fix(weather): handle disabled external connections (#6780) 2026-09-06 15:35:06 +02:00
47 changed files with 389 additions and 49 deletions

No files matched your search

@@ -20,7 +20,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
client-id: ${{ vars.RENOVATE_APPROVE_CLIENT_ID }}
permission-pull-requests: write # required to enable auto-merge
- name: Install GitHub CLI
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
client-id: ${{ vars.RENOVATE_APPROVE_CLIENT_ID }}
permission-pull-requests: write # required to approve a pull request and enable auto-merge
- name: Install GitHub CLI
@@ -22,7 +22,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app-id: ${{ secrets.CROWDIN_APP_ID }}
client-id: ${{ vars.CROWDIN_CLIENT_ID }}
permission-contents: write # required to commit to crowdin branch
permission-pull-requests: write # required to create pull request
@@ -52,7 +52,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
client-id: ${{ vars.RENOVATE_APPROVE_CLIENT_ID }}
permission-pull-requests: write
- name: Approve PR
env:
@@ -64,7 +64,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ vars.RENOVATE_MERGE_CLIENT_ID }}
permission-contents: write # write to beta branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
@@ -55,7 +55,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ vars.RENOVATE_MERGE_CLIENT_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
- uses: actions/checkout@v7
@@ -277,7 +277,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ vars.RENOVATE_MERGE_CLIENT_ID }}
permission-contents: write # required to publish the release
- name: Log in to the Container registry
@@ -73,7 +73,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
client-id: ${{ vars.RENOVATE_APPROVE_CLIENT_ID }}
permission-pull-requests: write
- name: Approve PR
if: ${{ steps.create-pull-request.outcome == 'success' }}
@@ -87,7 +87,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ vars.RENOVATE_MERGE_CLIENT_ID }}
permission-contents: write # write to main branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ vars.RENOVATE_MERGE_CLIENT_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_CLIENT_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
@@ -24,7 +24,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_CLIENT_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
+5 -1
View File
@@ -10,7 +10,11 @@
"CHANGELOG.md",
"apps/docs/.docusaurus/**",
"apps/docs/build/**",
"apps/docs/blog/**",
"apps/docs/blog/2022/**",
"apps/docs/blog/2023/**",
"apps/docs/blog/2024/**",
"apps/docs/blog/2025/**",
"apps/docs/blog/authors.yml",
"packages/custom-widgets/src/core/component-catalog.generated.json",
"apps/docs/static/custom-widgets/component-catalog-v1.json"
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,162 @@
---
authors:
- ajnart
tags: [homarr, update, version, assistant, custom widgets, dashboards, docker]
draft: false
---
# Homarr 2.0.0 - Workshop, Custom Widgets and a rebuilt board
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)
<!-- truncate -->
:::warning Back up before upgrading
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.
:::
## Custom Widgets v2 and Workshop
### Build almost any widget
**This is the big one.** We think it is the most important Homarr update we have made.
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.
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)
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.
### Share widgets and Custom CSS
**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)
:::warning Workshop is moderated
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.
:::
### 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.
## Board editing, rebuilt
### Drag and drop
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.
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### Goodbye Groups and sections, bonjour Containers
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)
### Board switcher
Boards now live in 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)
### Command menu and keybinds
<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.
### Customizable header
Pick which buttons appear, put them on the left, center or right, and change their order.
![The Header studio showing left, center, and right zones plus available controls](./img/header-studio.webp)
### Instance branding
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)
### 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.
### Docker and Podman
`DOCKER_ENDPOINTS` supports several hosts. Assisted setup and container labels can create apps, integrations, Containers, widgets and health checks.
![Assisted Docker setup listing discovered services that can become apps or integrations](./img/docker-assisted-setup.webp)
Homepage-compatible labels work too.
### 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 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 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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+25 -1
View File
@@ -14,7 +14,31 @@ docker exec -it <container-id> /bin/bash
homarr <command>
```
In QNAP Container Station, open the Homarr container's **Execute** action and select `/bin/bash`.
### QNAP Container Station
1. Open **Container Station**.
![Container Station in the QNAP application menu](./img/334116069-e7ec4726-3df7-4dc6-a3e0-b745e7eef17f.png)
2. Select **Containers**.
![Containers menu in QNAP Container Station](./img/334116205-aa09ce40-0701-4f7d-b9f9-b431b9d207c5.png)
3. Select the Homarr container.
![Homarr container selected in QNAP Container Station](./img/334116303-714b5e07-b9de-4ec0-a2bc-def7ee38750f.png)
4. Select **Execute**.
![Execute action for the Homarr container in QNAP Container Station](./img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png)
5. Select `/bin/bash`, then select **Execute**.
![Bash selected in the QNAP execute console](./img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png)
6. Run `homarr <command>` in the console.
![Bash console open for Homarr in QNAP Container Station](./img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png)
See the pages in this section for available commands.
@@ -31,7 +31,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `BASE_URL` | Public Homarr origin used by MCP OAuth metadata and redirects. Set this when a reverse proxy does not preserve the public host and protocol. | HTTP(S) origin without a path | - |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, location search, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
@@ -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",
},
+3 -2
View File
@@ -31,5 +31,6 @@ adds hourly temperature and precipitation charts and the complete seven-day fore
Weather and location data come from Open-Meteo. Active weather refreshes every ten minutes; identical location requests
can reuse a server response for five minutes. Failed refreshes keep the last successful data with an error indicator.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. Allowlist
`https://open-meteo.com` when outbound access is restricted.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. Weather data and the city search
inside the widget configuration are unavailable while it is enabled. Allowlist `https://open-meteo.com` when outbound
access is restricted.
+3
View File
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
@@ -37,6 +38,8 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return { results: [] };
const url = new URL("https://geocoding-api.open-meteo.com/v1/search");
url.searchParams.set("name", input.query);
const res = await withTimeoutAsync(async (signal) => {
+1
View File
@@ -3125,6 +3125,7 @@
}
},
"currentWindSpeed": "{currentWindSpeed} {unit}",
"disabled": "Weather data is unavailable because outbound connections are disabled",
"dailyForecast": {
"sunrise": "Sunrise",
"sunset": "Sunset",
@@ -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);
};
+12 -3
View File
@@ -1,10 +1,10 @@
"use client";
import { Box } from "@mantine/core";
import { Box, Center, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -19,6 +19,7 @@ export default function WeatherWidget({
height,
displayMode,
}: WidgetComponentProps<"weather">) {
const t = useI18n("widget.weather");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -27,7 +28,15 @@ export default function WeatherWidget({
const weather = getUsableWidgetQueryData(weatherQuery);
if (isInitialWidgetQueryPending(weatherQuery)) return <WidgetQueryLoadingState />;
if (!weather) return <WidgetEmptyState />;
if (!weather) {
return (
<Center h="100%" w="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("disabled")}
</Text>
</Center>
);
}
return (
<Box className={classes.weatherSurface} h="100%" w="100%" pos="relative">