Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620c13db9c | ||
|
|
58f4436485 | ||
|
|
81daa87b9e | ||
|
|
6db26a0d99 | ||
|
|
bbcbd031fe | ||
|
|
7024a57f56 | ||
|
|
5121356113 | ||
|
|
4f2ab59345 | ||
|
|
73bf68c9a8 | ||
|
|
dea1de5fee | ||
|
|
2bd538e685 | ||
|
|
f0c39ea0d2 | ||
|
|
8a0cf15bf7 | ||
|
|
f1c898efe2 | ||
|
|
724dee55c5 |
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
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.
|
||||
|
||||

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

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

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

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

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

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

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

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

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

|
||||
|
||||
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).
|
||||
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -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**.
|
||||
|
||||

|
||||
|
||||
2. Select **Containers**.
|
||||
|
||||

|
||||
|
||||
3. Select the Homarr container.
|
||||
|
||||

|
||||
|
||||
4. Select **Execute**.
|
||||
|
||||

|
||||
|
||||
5. Select `/bin/bash`, then select **Execute**.
|
||||
|
||||

|
||||
|
||||
6. Run `homarr <command>` in the console.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||