Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0b0af8f8 | ||
|
|
7e13ca54fb | ||
|
|
e161c8c512 | ||
|
|
a7a6cb6e07 | ||
|
|
bba20e4561 | ||
|
|
1030a21f4e | ||
|
|
f07b5c1f91 | ||
|
|
8f619be0d7 | ||
|
|
771db6d752 | ||
|
|
3edf874ca3 | ||
|
|
1b2f1bd720 | ||
|
|
c82965067c | ||
|
|
8abc9cae6c | ||
|
|
becc6a71a9 | ||
|
|
78a4f0e513 | ||
|
|
70e1b29913 | ||
|
|
73de83b48f | ||
|
|
26d12d9fad | ||
|
|
f0eddda573 | ||
|
|
b2c72d54c2 | ||
|
|
a56ec5d06a | ||
|
|
40e34181e7 | ||
|
|
6548da3772 | ||
|
|
3ee9b4f0c4 | ||
|
|
9303dbd284 |
No files matched your search
@@ -36,6 +36,8 @@ pnpm cli harness setup feature-board
|
||||
|
||||
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||
|
||||
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
@@ -55,7 +55,7 @@ The studio keeps related choices together:
|
||||
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
||||
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
||||
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
||||
|
||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Configure and use Homarr Assistant.
|
||||
description: Configure and use Assistant.
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
@@ -8,10 +8,10 @@ tags:
|
||||
|
||||
# Assistant
|
||||
|
||||
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||
approval before making changes.
|
||||
|
||||

|
||||

|
||||
|
||||
## Set it up
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ Change the radius of your items and Containers.
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
|
||||
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
|
||||
or Danger zone actions.
|
||||
|
||||
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
|
||||
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
||||
runtime components.
|
||||
|
||||
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
|
||||
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
|
||||
### Design with AI
|
||||
|
||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
|
||||
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
|
||||
update tool for your approval.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
|
||||
The optional [Assistant](./assistant) uses these same tool definitions internally.
|
||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||
approval before running mutating procedures.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { appWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -23,6 +23,8 @@ This widget adapts its display based on the linked integration:
|
||||
|
||||
Only options relevant to your linked integration are shown in the configuration panel.
|
||||
|
||||
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
|
||||
|
||||
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
|
||||
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
|
||||
|
||||
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
|
||||
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version.
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
|
||||
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height. Bulk actions keep successful results visible and report the number of failures.
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
|
||||
|
||||
:::info
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
|
||||
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
### Features
|
||||
|
||||
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
|
||||
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
|
||||
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
|
||||
@@ -72,11 +72,10 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
:::note
|
||||
|
||||
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
|
||||
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
|
||||
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
|
||||
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
|
||||
from the footer.
|
||||
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
|
||||
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
|
||||
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
|
||||
the filter panel available from the footer.
|
||||
|
||||
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
|
||||
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
|
||||
|
||||
@@ -16,6 +16,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
|
||||
|
||||
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
|
||||
|
||||
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
|
||||
|
||||
### Screenshots
|
||||
|
||||

|
||||
|
||||
@@ -15,6 +15,8 @@ import { immichServerStatsWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
|
||||
|
||||
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import screen from "./img/screen.png";
|
||||
|
||||
@@ -14,6 +14,8 @@ import { indexerManagerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
|
||||
|
||||
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import bigItem from "./img/big.png";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
|
||||
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
|
||||
|
||||
Provides a list of newly added media or upcoming releases from your media server.
|
||||
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
|
||||
|
||||
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
|
||||
|
||||
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
|
||||
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
|
||||
|
||||
Shows connected users and connected guest devices on wired or wireless networks.
|
||||
|
||||
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
|
||||
|
||||
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
|
||||
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
|
||||
|
||||
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
|
||||
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view.
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
|
||||
|
||||
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
|
||||
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
|
||||
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release.
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
|
||||
It also supports importing releases from your local docker containers.
|
||||
|
||||
### Screenshots
|
||||
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
|
||||
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
|
||||
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
|
||||
|
||||
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
|
||||
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
|
||||
|
||||
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
|
||||
|
||||
The entity state enables you to display the current state of an entity.
|
||||
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
|
||||
This works with most entities and enables you display almost any data.
|
||||
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
|
||||
|
||||
The Timetable widget provides real-time departure times for a selected station.
|
||||
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
|
||||
|
||||
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
|
||||
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
|
||||
|
||||
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
|
||||
|
||||
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
|
||||
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
|
||||
|
||||
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami";
|
||||
|
||||
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
|
||||
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
|
||||
|
||||
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
|
||||
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height.
|
||||
|
||||
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
|
||||
|
||||
|
||||
+351
@@ -3453,6 +3453,9 @@
|
||||
"enableRightClickOnWidgets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"headerPreferences": {
|
||||
"type": "string"
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3488,6 +3491,7 @@
|
||||
"firstDayOfWeek",
|
||||
"pingIconsEnabled",
|
||||
"enableRightClickOnWidgets",
|
||||
"headerPreferences",
|
||||
"defaultSearchEngineId",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
@@ -4346,6 +4350,353 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/header-preferences": {
|
||||
"patch": {
|
||||
"operationId": "userRouter-changeHeaderPreferences",
|
||||
"tags": ["users"],
|
||||
"security": [
|
||||
{
|
||||
"apikey": []
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"headerPreferences": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 3
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"searchDisplay": {
|
||||
"type": "string",
|
||||
"enum": ["input", "icon"]
|
||||
},
|
||||
"logoDisplay": {
|
||||
"default": "logoAndText",
|
||||
"type": "string",
|
||||
"enum": ["logo", "logoAndText"]
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"center": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["left", "center", "right"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "searchDisplay", "zones"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 1
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"minItems": 1,
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "items"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 2
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
},
|
||||
"center": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["left", "center", "right"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "zones"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "headerPreferences"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.NOT_FOUND"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/ddg-bangs": {
|
||||
"patch": {
|
||||
"operationId": "userRouter-changeDdgBangs",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
import { theme } from "@homarr/ui";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
|
||||
import { createBrandTheme } from "~/theme/branding";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
||||
branding,
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
|
||||
const manager = useColorSchemeManager();
|
||||
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={theme}
|
||||
theme={configuredTheme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { IconUserPlus } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
|
||||
}
|
||||
|
||||
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
if (!isProviderEnabled("credentials")) notFound();
|
||||
|
||||
const session = await auth();
|
||||
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
|
||||
if (session) notFound();
|
||||
|
||||
const invite = await db.query.invites.findFirst({
|
||||
@@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const t = await getI18n("user.page.invite");
|
||||
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
|
||||
const branding = serverSettings.branding;
|
||||
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
|
||||
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
|
||||
const defaultDescription = showCustomAppName
|
||||
? t("subtitleWithAppName", { appName: branding.appName })
|
||||
: t("subtitle");
|
||||
const description = showCustomGreeting ? branding.greeting : defaultDescription;
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={<IconUserPlus size={24} />}
|
||||
appName={branding.appName}
|
||||
showAppName={branding.authBranding.showAppName}
|
||||
showAppLogo={branding.authBranding.showLogo}
|
||||
primaryColor={branding.primaryColor}
|
||||
secondaryColor={branding.secondaryColor}
|
||||
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||
radius={branding.defaultRadius}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
footer={t("description", { username: invite.creator.name! })}
|
||||
>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { redirect } from "next/navigation";
|
||||
import { Alert, Code, Text } from "@mantine/core";
|
||||
import { IconLogin } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getBrandingColorOverrides } from "@homarr/server-settings";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
@@ -19,17 +21,35 @@ interface LoginProps {
|
||||
}
|
||||
|
||||
export default async function Login(props: LoginProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
|
||||
|
||||
if (session) {
|
||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||
}
|
||||
|
||||
const t = await getI18n("user.page.login");
|
||||
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
|
||||
const branding = serverSettings.branding;
|
||||
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||
const description = showCustomGreeting ? branding.greeting : t("subtitle");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
<OnboardingAuthShell
|
||||
title={t("title")}
|
||||
description={description}
|
||||
icon={<IconLogin size={24} />}
|
||||
appName={branding.appName}
|
||||
showAppName={branding.authBranding.showAppName}
|
||||
showAppLogo={branding.authBranding.showLogo}
|
||||
primaryColor={branding.primaryColor}
|
||||
secondaryColor={branding.secondaryColor}
|
||||
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||
radius={branding.defaultRadius}
|
||||
>
|
||||
{appEnv.DEMO_MODE ? (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
|
||||
<Text size="sm" fw={500}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
@@ -36,7 +36,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
@@ -103,18 +104,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n("board");
|
||||
const [board, t, serverSettings] = await Promise.all([
|
||||
getInitialBoard(await params),
|
||||
getI18n("board"),
|
||||
getRscServerSettingsAsync(),
|
||||
]);
|
||||
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
|
||||
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
|
||||
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
||||
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
|
||||
icons: {
|
||||
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
icon: favicon,
|
||||
apple: favicon,
|
||||
},
|
||||
appleWebApp: {
|
||||
startupImage: {
|
||||
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
|
||||
url: favicon,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) return null;
|
||||
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess || demoReadOnly) return null;
|
||||
|
||||
return (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||
import { generateColorScale } from "~/theme/branding";
|
||||
|
||||
export const BoardMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
const colorSchemeManager = useColorSchemeManager();
|
||||
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
|
||||
|
||||
const theme = createTheme({
|
||||
colors: {
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||
primaryColor: generateColorScale(primaryColor),
|
||||
secondaryColor: generateColorScale(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
defaultRadius: "sm",
|
||||
defaultRadius: branding.defaultRadius,
|
||||
fontSizes: {
|
||||
"2xl": rem(24),
|
||||
"3xl": rem(28),
|
||||
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateColors = (hex: string) => {
|
||||
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
|
||||
const rgbaColors = lightnessForColors.map((lightness) => {
|
||||
if (lightness < 0) {
|
||||
return lighten(hex, -lightness);
|
||||
}
|
||||
return darken(hex, lightness);
|
||||
});
|
||||
|
||||
return rgbaColors.map((color) => {
|
||||
return (
|
||||
"#" +
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
color
|
||||
.split("(")[1]!
|
||||
.replaceAll(" ", "")
|
||||
.replace(")", "")
|
||||
.split(",")
|
||||
.map((color) => parseInt(color, 10))
|
||||
.slice(0, 3)
|
||||
.map((color) => color.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
);
|
||||
}) as unknown as MantineColorsTuple;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconHomeOff } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
|
||||
export default async function NotFoundBoardHomePage() {
|
||||
const boardNotFoundProps = await getPropsAsync();
|
||||
@@ -19,8 +21,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
const t = await getI18n("board");
|
||||
|
||||
if (boardCount === 0) {
|
||||
const { branding } = await getRscServerSettingsAsync();
|
||||
return {
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
|
||||
title: t("error.noBoard.title"),
|
||||
description: t("error.noBoard.description"),
|
||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||
|
||||
@@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput } from "@homarr/ui";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import { generateColorScale } from "~/theme/branding";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
@@ -40,13 +41,19 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const theme = useMantineTheme();
|
||||
const { branding } = useSettings();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
<BoardColorInput
|
||||
label={tBoard("field.primaryColor.label")}
|
||||
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
|
||||
disabled={branding.lockPrimaryColor}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
@@ -121,7 +128,8 @@ interface ColorsPreviewProps {
|
||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
|
||||
const colors =
|
||||
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
@@ -21,20 +21,26 @@ interface Props {
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n("board");
|
||||
const { branding } = useSettings();
|
||||
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
||||
<TextInput
|
||||
label={t("field.pageTitle.label")}
|
||||
placeholder={branding.appName}
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
||||
placeholder={defaultMetaTitle}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
@@ -43,7 +49,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -51,7 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -72,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
|
||||
}, [logoDebounced, updateBoard]);
|
||||
};
|
||||
|
||||
const useMetaTitlePreview = (title: string | null) => {
|
||||
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
|
||||
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
|
||||
useDocumentTitle(metaTitleDebounced);
|
||||
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
|
||||
|
||||
return {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
|
||||
@@ -10,10 +10,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
@@ -88,6 +89,7 @@ interface BoardSettingsFormProps {
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n("common");
|
||||
const tSection = useI18n("board.setting.section");
|
||||
const { branding } = useSettings();
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
@@ -142,7 +144,9 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const [, canonicalLayouts] = await Promise.all([
|
||||
partialSettingsChanged
|
||||
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
updateFavicon(
|
||||
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
|
||||
);
|
||||
})
|
||||
: Promise.resolve(),
|
||||
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
|
||||
|
||||
@@ -12,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { appShellLogoHeight } from "~/components/layout/constants";
|
||||
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
@@ -27,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
headerActions?: JSX.Element;
|
||||
headerBoardEditAction?: JSX.Element;
|
||||
headerBoardSettingsAction?: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
headerBoardEditAction,
|
||||
headerBoardSettingsAction,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
@@ -105,8 +110,11 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
logo={<BoardLogo size={appShellLogoHeight} />}
|
||||
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
boardEditAction={headerBoardEditAction}
|
||||
boardSettingsAction={headerBoardSettingsAction}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SettingsProvider } from "@homarr/settings";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
@@ -53,35 +54,45 @@ const fontSans = Inter({
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
openGraph: {
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo/logo.png",
|
||||
apple: "/logo/logo.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
title: "Homarr",
|
||||
capable: true,
|
||||
startupImage: { url: "/logo/logo.png" },
|
||||
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
});
|
||||
export const generateMetadata = async (): Promise<Metadata> => {
|
||||
const [serverSettings, colorScheme, t] = await Promise.all([
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
getI18n("metadata"),
|
||||
]);
|
||||
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
|
||||
const logo = logoImageUrl ?? "/logo/logo.png";
|
||||
const favicon = faviconImageUrl ?? logo;
|
||||
const description = t("description");
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
return {
|
||||
title: {
|
||||
default: appName,
|
||||
template: `%s • ${appName}`,
|
||||
},
|
||||
description,
|
||||
openGraph: {
|
||||
title: t("dashboardTitle", { appName }),
|
||||
description,
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: appName,
|
||||
},
|
||||
icons: {
|
||||
icon: favicon,
|
||||
apple: logo,
|
||||
},
|
||||
appleWebApp: {
|
||||
title: appName,
|
||||
capable: true,
|
||||
startupImage: { url: logo },
|
||||
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const generateViewport = async (): Promise<Viewport> => {
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
return { themeColor: serverSettings.branding.primaryColor };
|
||||
};
|
||||
|
||||
export default async function Layout(props: {
|
||||
@@ -94,6 +105,7 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
const sessionPromise = auth();
|
||||
const serverSettingsPromise = getRscServerSettingsAsync();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
@@ -102,18 +114,17 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
||||
!session
|
||||
? "unauthenticated"
|
||||
: api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error"),
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||
if (!session) return "unauthenticated";
|
||||
return api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error");
|
||||
});
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
serverSettingsPromise,
|
||||
getCurrentColorSchemeAsync(),
|
||||
assistantAvailabilityPromise,
|
||||
]);
|
||||
@@ -148,6 +159,7 @@ export default async function Layout(props: {
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||
branding: serverSettings.branding,
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
@@ -156,7 +168,9 @@ export default async function Layout(props: {
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => (
|
||||
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
|
||||
),
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getI18n("management.page.home");
|
||||
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
|
||||
const { appName, logoImageUrl } = serverSettings.branding;
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
@@ -14,9 +16,17 @@ export const HeroBanner = async () => {
|
||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||
{t("heroBanner.title")}
|
||||
</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Image
|
||||
src={logoImageUrl ?? "/logo/logo.png"}
|
||||
alt={`${appName} logo`}
|
||||
w="auto"
|
||||
h={{ base: 48, md: 64 }}
|
||||
maw={{ base: 128, md: 192 }}
|
||||
fit="contain"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
|
||||
@@ -37,9 +37,8 @@ import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
@@ -50,7 +49,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: `${t("title")} • Homarr`,
|
||||
title: t("title"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
@@ -74,6 +74,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.home"),
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
exact: true,
|
||||
"data-onboarding-tour-id": "manage-welcome",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,14 +7,13 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
ColorSwatch,
|
||||
Fieldset,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
interface BrandingSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.branding");
|
||||
const logoInput = form.getInputProps("branding.logoImageUrl");
|
||||
const faviconInput = form.getInputProps("branding.faviconImageUrl");
|
||||
const backgroundInput = form.getInputProps("branding.signInBackgroundImageUrl");
|
||||
const branding = form.values.branding;
|
||||
|
||||
return (
|
||||
<SectionCard title={t("title")}>
|
||||
<Stack gap="md">
|
||||
<Fieldset legend={t("groups.identity")} p="sm">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("identity.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm" verticalSpacing="sm">
|
||||
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
|
||||
<IconPicker
|
||||
label={t("logo.label")}
|
||||
description={null}
|
||||
placeholder={t("logo.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={logoInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.logoImageUrl", value || null)}
|
||||
error={logoInput.error}
|
||||
/>
|
||||
<IconPicker
|
||||
label={t("favicon.label")}
|
||||
description={null}
|
||||
placeholder={t("favicon.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={faviconInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.faviconImageUrl", value || null)}
|
||||
error={faviconInput.error}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend={t("groups.appearance")} p="sm">
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<BoardColorInput
|
||||
label={t("primaryColor.label")}
|
||||
defaultColor="#fa5252"
|
||||
{...form.getInputProps("branding.primaryColor")}
|
||||
/>
|
||||
<BoardColorInput
|
||||
label={t("secondaryColor.label")}
|
||||
defaultColor="#fd7e14"
|
||||
{...form.getInputProps("branding.secondaryColor")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Switch
|
||||
label={t("lockPrimaryColor.label")}
|
||||
description={t("lockPrimaryColor.description")}
|
||||
{...form.getInputProps("branding.lockPrimaryColor", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
<CornerStylePicker
|
||||
compact
|
||||
label={t("defaultRadius.label")}
|
||||
value={branding.defaultRadius}
|
||||
labels={
|
||||
Object.fromEntries(
|
||||
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]),
|
||||
) as Record<(typeof cornerStyleValues)[number], string>
|
||||
}
|
||||
onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend={t("groups.authentication")} p="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
|
||||
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
|
||||
<IconPicker
|
||||
label={t("signInBackground.label")}
|
||||
description={null}
|
||||
placeholder={t("signInBackground.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={backgroundInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.signInBackgroundImageUrl", value || null)}
|
||||
error={backgroundInput.error}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap={6} justify="center">
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("signInOverlay.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(branding.signInBackgroundOverlay * 100)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Slider
|
||||
aria-label={t("signInOverlay.label")}
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
label={(value) => `${Math.round(value * 100)}%`}
|
||||
disabled={!branding.signInBackgroundImageUrl}
|
||||
value={branding.signInBackgroundOverlay}
|
||||
onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("authVisibility.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.appName")}
|
||||
{...form.getInputProps("branding.authBranding.showAppName", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.logo")}
|
||||
{...form.getInputProps("branding.authBranding.showLogo", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.greeting")}
|
||||
{...form.getInputProps("branding.authBranding.showGreeting", { type: "checkbox" })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<BrandingSummary form={form} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const BrandingSummary = ({ form }: BrandingSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.branding");
|
||||
const branding = form.values.branding;
|
||||
const appName = branding.appName.trim() || "Homarr";
|
||||
const logoImageUrl = branding.logoImageUrl || "/logo/logo.png";
|
||||
const faviconImageUrl = branding.faviconImageUrl || logoImageUrl;
|
||||
const backgroundStatus = branding.signInBackgroundImageUrl
|
||||
? t("summary.backgroundConfigured", { overlay: Math.round(branding.signInBackgroundOverlay * 100) })
|
||||
: t("summary.backgroundDefault");
|
||||
const greetingStatus = branding.greeting.trim() ? t("summary.greetingConfigured") : t("summary.greetingDefault");
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius={branding.defaultRadius}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="xs" wrap="wrap">
|
||||
<Text fw={650}>{t("summary.title")}</Text>
|
||||
<Badge variant="light">
|
||||
{t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("groups.identity")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar.Group>
|
||||
<Avatar src={logoImageUrl} alt={t("logo.label")} />
|
||||
<Avatar src={faviconImageUrl} alt={t("favicon.label")} />
|
||||
</Avatar.Group>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{appName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("summary.palette")}
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ColorSwatch color={branding.primaryColor} size={20} />
|
||||
<Text size="xs">{branding.primaryColor}</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ColorSwatch color={branding.secondaryColor} size={20} />
|
||||
<Text size="xs">{branding.secondaryColor}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Badge color={branding.lockPrimaryColor ? "primaryColor" : "gray"} variant="light" w="fit-content">
|
||||
{branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("groups.authentication")}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{backgroundStatus} · {greetingStatus}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<VisibilityBadge label={t("summary.appName")} visible={branding.authBranding.showAppName} />
|
||||
<VisibilityBadge label={t("summary.logo")} visible={branding.authBranding.showLogo} />
|
||||
<VisibilityBadge label={t("summary.greeting")} visible={branding.authBranding.showGreeting} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => {
|
||||
const t = useI18n("management.page.settings.section.branding.summary");
|
||||
|
||||
return (
|
||||
<Badge size="xs" color={visible ? "green" : "gray"} variant="light">
|
||||
{label}: {visible ? t("on") : t("off")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -12,12 +12,14 @@ import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { brandingServerSettingsSchema } from "@homarr/server-settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
import { AnalyticsSettings } from "./analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { BrandingSettingsForm } from "./branding-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
@@ -37,6 +39,7 @@ const settingsFormSchema = z.object({
|
||||
defaultSearchEngineId: z.string().nullable(),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
branding: brandingServerSettingsSchema,
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
@@ -55,6 +58,7 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
branding: initialData.branding,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
@@ -137,6 +141,11 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
{
|
||||
settingsKey: "branding",
|
||||
when: changed("branding"),
|
||||
value: values.branding,
|
||||
},
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
@@ -176,6 +185,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<BrandingSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
|
||||
@@ -9,10 +9,9 @@ import { SettingsForm } from "./_components/settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||
import { ApiKeysManagement } from "./components/api-keys";
|
||||
@@ -63,7 +62,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { BackupExportCard } from "./_components/backup-export-card";
|
||||
import { BackupImportCard } from "./_components/backup-import-card";
|
||||
@@ -19,7 +18,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.tool.backup");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { ClientSideTerminalComponent } from "./client";
|
||||
import { LogFontSizeSlider } from "./font-size-slider";
|
||||
import { LogLevelSelection } from "./level-selection";
|
||||
@@ -25,7 +24,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { TasksTable } from "./_components/tasks-table";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -17,7 +16,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+163
-130
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
@@ -22,12 +24,15 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
headerPreferencesSchema,
|
||||
parseHeaderPreferences,
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
@@ -36,9 +41,9 @@ import {
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HeaderComposer } from "./header-composer";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
@@ -53,6 +58,7 @@ const userGeneralSettingsSchema = z.object({
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
headerPreferences: headerPreferencesSchema,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -65,9 +71,10 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
||||
|
||||
interface UserGeneralSettingsFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
showLanguageSelector?: boolean;
|
||||
profileAvatar?: ReactNode;
|
||||
}
|
||||
|
||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||
@@ -81,6 +88,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
headerPreferences: parseHeaderPreferences(user.headerPreferences),
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -88,6 +96,7 @@ export const UserGeneralSettingsForm = ({
|
||||
boardsData,
|
||||
searchEnginesData,
|
||||
showLanguageSelector = false,
|
||||
profileAvatar,
|
||||
}: UserGeneralSettingsFormProps) => {
|
||||
const tUser = useI18n("user");
|
||||
const tCommon = useI18n("common");
|
||||
@@ -101,6 +110,7 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -121,6 +131,7 @@ export const UserGeneralSettingsForm = ({
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
changeHeaderPreferencesMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -184,6 +195,14 @@ export const UserGeneralSettingsForm = ({
|
||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("headerPreferences"),
|
||||
action: () =>
|
||||
changeHeaderPreferencesMutation.mutateAsync({
|
||||
id: user.id,
|
||||
headerPreferences: parsed.data.headerPreferences,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
@@ -218,135 +237,149 @@ export const UserGeneralSettingsForm = ({
|
||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tUser("name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
<HeaderComposer
|
||||
value={form.values.headerPreferences}
|
||||
onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
|
||||
boards={boardsData}
|
||||
homeBoardId={form.values.homeBoardId}
|
||||
mobileHomeBoardId={form.values.mobileHomeBoardId}
|
||||
/>
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tUser("name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={tUser("field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={tUser("field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{tCommon("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={tUser("field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={tUser("field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{tCommon("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</form>
|
||||
</Box>
|
||||
{profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
.composer {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.tray {
|
||||
padding: var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.heading {
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 21rem;
|
||||
padding: var(--mantine-spacing-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
.device {
|
||||
position: relative;
|
||||
width: min(100%, 34rem);
|
||||
min-height: 18rem;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
transition:
|
||||
width 180ms ease,
|
||||
min-height 180ms ease;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 5rem;
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.zoneTable {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.zoneColumn {
|
||||
min-width: 0;
|
||||
border-inline-end: 1px solid var(--mantine-color-default-border);
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.zoneColumn:last-child {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.zoneColumn[data-drop-target="true"] {
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.zoneColumn[data-drop-target="true"] .zoneItems::after {
|
||||
content: "";
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
|
||||
}
|
||||
|
||||
.zoneColumnHeader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 1.75rem;
|
||||
margin: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoneItems {
|
||||
display: flex;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sortableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sortableItem[data-drop-target="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0.2rem;
|
||||
inset-inline-start: -0.2rem;
|
||||
z-index: 1;
|
||||
width: 2px;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
background: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.sortableItem[data-dragging="true"] {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.itemButton,
|
||||
.dragOverlay {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
cursor: grab;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.itemButton[data-wide="true"],
|
||||
.dragOverlay[data-wide="true"] {
|
||||
width: 4.75rem;
|
||||
}
|
||||
|
||||
.itemButton:hover,
|
||||
.itemButton:focus-visible,
|
||||
.sortableItem[data-drop-target="true"] .itemButton,
|
||||
.sortableItem[data-selected="true"] .itemButton {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background: var(--mantine-primary-color-light-hover);
|
||||
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.itemButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dragOverlay {
|
||||
pointer-events: none;
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
.searchPreview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.logoPreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logoPreview[data-with-title="true"] {
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
.logoPreview span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchPreview[data-input="true"] {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.375rem;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.searchPreview span {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hiddenPreview {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.floatingAccount {
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
.dashboardPreview {
|
||||
height: 13rem;
|
||||
overflow: hidden;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.dashboardLayout {
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.tray {
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.configuration {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 42%, var(--mantine-color-body));
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.heading,
|
||||
.tray,
|
||||
.stage {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.stage {
|
||||
min-height: 19rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.device,
|
||||
.zoneColumn,
|
||||
.itemButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PointerActivationConstraints } from "@dnd-kit/dom";
|
||||
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
|
||||
import type { DragEndEvent } from "@dnd-kit/react";
|
||||
import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react";
|
||||
import { useSortable } from "@dnd-kit/react/sortable";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
IconUserCircle,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type {
|
||||
HeaderBuiltinItemId,
|
||||
HeaderItem,
|
||||
HeaderPreferences,
|
||||
HeaderZoneId,
|
||||
HeaderZones,
|
||||
} from "@homarr/validation/user";
|
||||
import {
|
||||
createBoardHeaderItem,
|
||||
createBuiltinHeaderItem,
|
||||
getDefaultHeaderZone,
|
||||
getHeaderItemKey,
|
||||
getHeaderItems,
|
||||
getHeaderItemZone,
|
||||
headerBuiltinItemIds,
|
||||
isRequiredHeaderItem,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { HomarrLogo } from "~/components/layout/logo/homarr-logo";
|
||||
import classes from "./header-composer.module.css";
|
||||
|
||||
interface HeaderComposerProps {
|
||||
value: HeaderPreferences;
|
||||
onChange: (value: HeaderPreferences) => void;
|
||||
boards: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
}
|
||||
|
||||
interface HeaderItemDragData {
|
||||
kind: "item";
|
||||
itemKey: string;
|
||||
zone: HeaderZoneId;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface HeaderZoneDropData {
|
||||
kind: "zone";
|
||||
zone: HeaderZoneId;
|
||||
}
|
||||
|
||||
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
|
||||
|
||||
const headerSortableSensors = [
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })],
|
||||
}),
|
||||
KeyboardSensor,
|
||||
];
|
||||
const headerSortableGroup = "header-composer";
|
||||
const headerSortablePlugins = [SortableKeyboardPlugin];
|
||||
const headerGuideZones = ["left", "center", "right"] as const;
|
||||
|
||||
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
|
||||
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const zoneTableRef = useRef<HTMLDivElement>(null);
|
||||
const { branding } = useSettings();
|
||||
const t = useI18n("management.page.user.setting.general.header");
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
const activeItems = getHeaderItems(value.zones);
|
||||
|
||||
useEffect(() => {
|
||||
const zoneTable = zoneTableRef.current;
|
||||
if (!zoneTable) return;
|
||||
|
||||
const clearSelectionFromEmptyArea = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest("[data-header-composer-item]")) return;
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
zoneTable.addEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||
return () => zoneTable.removeEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||
}, []);
|
||||
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
|
||||
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
|
||||
});
|
||||
const availableBoards = boards.filter(
|
||||
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
|
||||
);
|
||||
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
|
||||
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
|
||||
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
|
||||
|
||||
const toggleSelectedItem = (itemKey: string) => {
|
||||
setSelectedItemKey((currentItemKey) => {
|
||||
if (currentItemKey === itemKey) return null;
|
||||
return itemKey;
|
||||
});
|
||||
};
|
||||
|
||||
const itemLabel = (item: HeaderItem) => {
|
||||
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
|
||||
return t(`items.${item.id}` as never);
|
||||
};
|
||||
|
||||
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
|
||||
|
||||
const removeItem = (item: HeaderItem) => {
|
||||
if (isRequiredHeaderItem(item)) return;
|
||||
const zone = getHeaderItemZone(value.zones, item);
|
||||
if (!zone) return;
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
updateZones({
|
||||
...value.zones,
|
||||
[zone]: value.zones[zone].filter((candidate) => getHeaderItemKey(candidate) !== itemKey),
|
||||
});
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
const addItem = (item: HeaderItem) => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
if (activeItemKeySet.has(itemKey)) return;
|
||||
const zone = getDefaultHeaderZone(item);
|
||||
updateZones({ ...value.zones, [zone]: [...value.zones[zone], item] });
|
||||
setSelectedItemKey(itemKey);
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ canceled, operation }: DragEndEvent) => {
|
||||
setDraggedItemKey(null);
|
||||
const source = operation.source;
|
||||
const target = operation.target;
|
||||
if (canceled || !source || !target) return;
|
||||
|
||||
const sourceData = source.data as HeaderItemDragData;
|
||||
const targetData = target.data as HeaderDropData;
|
||||
if (sourceData.kind !== "item") return;
|
||||
|
||||
let targetIndex = value.zones[targetData.zone].length;
|
||||
if (targetData.kind === "item") {
|
||||
if (targetData.itemKey === sourceData.itemKey) return;
|
||||
targetIndex = targetData.index;
|
||||
}
|
||||
|
||||
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
|
||||
if (nextZones === value.zones) return;
|
||||
updateZones(nextZones);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={classes.composer} padding={0} radius="lg" withBorder>
|
||||
<Stack gap={0}>
|
||||
<Group className={classes.heading} justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" radius="md" size="lg">
|
||||
<IconLayoutDashboard size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="lg">
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant={value.visible ? "default" : "filled"}
|
||||
aria-label={value.visible ? t("action.hide") : t("action.show")}
|
||||
onClick={() => onChange({ ...value, visible: !value.visible })}
|
||||
>
|
||||
{value.visible ? <IconEye size={18} /> : <IconEyeOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box className={classes.stage} data-visible={value.visible}>
|
||||
<Paper className={classes.device} radius="lg" withBorder>
|
||||
{value.visible ? (
|
||||
<DragDropProvider
|
||||
onDragStart={({ operation }) => setDraggedItemKey(operation.source?.id.toString() ?? null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={classes.previewHeader}>
|
||||
<div ref={zoneTableRef} className={classes.zoneTable}>
|
||||
{headerGuideZones.map((zone) => (
|
||||
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
|
||||
{value.zones[zone].map((item, index) => (
|
||||
<SortableHeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
item={item}
|
||||
zone={zone}
|
||||
index={index}
|
||||
label={itemLabel(item)}
|
||||
searchDisplay={value.searchDisplay}
|
||||
logoDisplay={value.logoDisplay}
|
||||
logoTitle={branding.appName}
|
||||
boardLogo={
|
||||
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
|
||||
}
|
||||
configureLabel={t("action.configure", { item: itemLabel(item) })}
|
||||
selected={selectedItemKey === getHeaderItemKey(item)}
|
||||
onSelect={toggleSelectedItem}
|
||||
/>
|
||||
))}
|
||||
</HeaderZoneColumn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedItem ? (
|
||||
<HeaderItemPreview
|
||||
item={draggedItem}
|
||||
label={itemLabel(draggedItem)}
|
||||
compact={false}
|
||||
searchDisplay={value.searchDisplay}
|
||||
logoDisplay={value.logoDisplay}
|
||||
logoTitle={branding.appName}
|
||||
boardLogo={
|
||||
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
|
||||
}
|
||||
overlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
) : (
|
||||
<div className={classes.hiddenPreview}>
|
||||
<ThemeIcon className={classes.floatingAccount} radius="xl" size="xl" aria-label={t("items.user")}>
|
||||
<IconUserCircle size={22} />
|
||||
</ThemeIcon>
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.dashboardPreview}>
|
||||
<BoardLayoutThumbnail
|
||||
preview={previewBoard?.preview ?? null}
|
||||
label={previewBoard ? t("preview.board", { name: previewBoard.name }) : t("preview.empty")}
|
||||
className={classes.dashboardLayout}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Stack className={classes.tray} gap="sm">
|
||||
{selectedItem ? (
|
||||
<Paper className={classes.configuration} p="sm" radius="md" withBorder>
|
||||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={650} size="sm">
|
||||
{itemLabel(selectedItem)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("configuration.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{selectedItem.type === "builtin" && selectedItem.id === "search" ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value.searchDisplay}
|
||||
onChange={(searchDisplay) =>
|
||||
onChange({ ...value, searchDisplay: searchDisplay as HeaderPreferences["searchDisplay"] })
|
||||
}
|
||||
data={[
|
||||
{ value: "input", label: t("configuration.searchDisplay.input") },
|
||||
{ value: "icon", label: t("configuration.searchDisplay.icon") },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem.type === "builtin" && selectedItem.id === "logo" ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value.logoDisplay}
|
||||
onChange={(logoDisplay) =>
|
||||
onChange({ ...value, logoDisplay: logoDisplay as HeaderPreferences["logoDisplay"] })
|
||||
}
|
||||
data={[
|
||||
{ value: "logo", label: t("configuration.logoDisplay.logo") },
|
||||
{ value: "logoAndText", label: t("configuration.logoDisplay.logoAndText") },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{!isRequiredHeaderItem(selectedItem) ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => removeItem(selectedItem)}
|
||||
>
|
||||
{t("action.remove", { item: itemLabel(selectedItem) })}
|
||||
</Button>
|
||||
) : (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.requiredAlways")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Stack gap={0}>
|
||||
<Text fw={650} size="sm">
|
||||
{t("available.title")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
disabled={availableBoards.length === 0}
|
||||
>
|
||||
{t("available.boardShortcut")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("available.chooseBoard")}</Menu.Label>
|
||||
{availableBoards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
leftSection={
|
||||
<Avatar src={board.logoImageUrl} size="xs" radius="sm">
|
||||
<IconLayoutDashboard size={14} />
|
||||
</Avatar>
|
||||
}
|
||||
onClick={() => addItem(createBoardHeaderItem(board.id))}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{inactiveBuiltinIds.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("available.allAdded")}
|
||||
</Text>
|
||||
) : (
|
||||
inactiveBuiltinIds.map((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
const Icon = getBuiltinItemIcon(itemId);
|
||||
return (
|
||||
<Tooltip key={itemId} label={t("available.add", { item: itemLabel(item) })}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="default"
|
||||
aria-label={t("available.add", { item: itemLabel(item) })}
|
||||
onClick={() => addItem(item)}
|
||||
>
|
||||
<Icon size={19} stroke={1.7} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeaderZoneColumnProps {
|
||||
zone: HeaderZoneId;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
|
||||
const { isDropTarget, ref } = useDroppable({
|
||||
id: `header-zone:${zone}`,
|
||||
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
|
||||
});
|
||||
|
||||
return (
|
||||
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
|
||||
<Text className={classes.zoneColumnHeader} component="h4">
|
||||
{label}
|
||||
</Text>
|
||||
<div className={classes.zoneItems}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableHeaderItemProps {
|
||||
item: HeaderItem;
|
||||
zone: HeaderZoneId;
|
||||
index: number;
|
||||
label: string;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null;
|
||||
configureLabel: string;
|
||||
selected: boolean;
|
||||
onSelect: (itemKey: string) => void;
|
||||
}
|
||||
|
||||
const SortableHeaderItem = ({
|
||||
item,
|
||||
zone,
|
||||
index,
|
||||
label,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
configureLabel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: SortableHeaderItemProps) => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
|
||||
id: itemKey,
|
||||
index,
|
||||
group: `${headerSortableGroup}:${zone}`,
|
||||
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
|
||||
sensors: headerSortableSensors,
|
||||
plugins: headerSortablePlugins,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes.sortableItem}
|
||||
data-dragging={isDragging || undefined}
|
||||
data-drop-target={isDropTarget || undefined}
|
||||
data-entry-id={itemKey}
|
||||
data-selected={selected || undefined}
|
||||
>
|
||||
<button
|
||||
ref={handleRef}
|
||||
type="button"
|
||||
className={classes.itemButton}
|
||||
data-header-composer-item
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-wide={
|
||||
(item.type === "builtin" &&
|
||||
((item.id === "search" && searchDisplay === "input") ||
|
||||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||
undefined
|
||||
}
|
||||
aria-label={configureLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(itemKey);
|
||||
}}
|
||||
>
|
||||
<HeaderItemPreview
|
||||
item={item}
|
||||
label={label}
|
||||
compact={false}
|
||||
searchDisplay={searchDisplay}
|
||||
logoDisplay={logoDisplay}
|
||||
logoTitle={logoTitle}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderItemPreview = ({
|
||||
item,
|
||||
label,
|
||||
compact,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
overlay = false,
|
||||
}: {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
compact: boolean;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null | undefined;
|
||||
overlay?: boolean;
|
||||
}) => {
|
||||
const content = (
|
||||
<PreviewItem
|
||||
item={item}
|
||||
label={label}
|
||||
compact={compact}
|
||||
searchDisplay={searchDisplay}
|
||||
logoDisplay={logoDisplay}
|
||||
logoTitle={logoTitle}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
);
|
||||
if (!overlay) return content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.dragOverlay}
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-wide={
|
||||
(item.type === "builtin" &&
|
||||
!compact &&
|
||||
((item.id === "search" && searchDisplay === "input") ||
|
||||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PreviewItem = ({
|
||||
item,
|
||||
label,
|
||||
compact,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
}: {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
compact: boolean;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null | undefined;
|
||||
}) => {
|
||||
if (item.type === "board") {
|
||||
return (
|
||||
<Avatar src={boardLogo} size={22} radius="sm">
|
||||
<IconLayoutDashboard size={17} />
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "logo") {
|
||||
return (
|
||||
<div className={classes.logoPreview} data-with-title={logoDisplay === "logoAndText" || undefined}>
|
||||
<HomarrLogo size={26} />
|
||||
{logoDisplay === "logoAndText" ? <span>{logoTitle}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "search") {
|
||||
const showInput = searchDisplay === "input" && !compact;
|
||||
return (
|
||||
<div className={classes.searchPreview} data-input={showInput || undefined}>
|
||||
<IconSearch size={18} />
|
||||
{showInput ? <span>{label}…</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = getBuiltinItemIcon(item.id);
|
||||
return <Icon size={20} stroke={1.7} />;
|
||||
};
|
||||
|
||||
const moveHeaderItem = (
|
||||
zones: HeaderZones,
|
||||
itemKey: string,
|
||||
targetZone: HeaderZoneId,
|
||||
requestedTargetIndex: number,
|
||||
): HeaderZones => {
|
||||
let sourceItem: HeaderItem | undefined;
|
||||
|
||||
for (const zone of headerGuideZones) {
|
||||
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
|
||||
if (sourceItem) break;
|
||||
}
|
||||
|
||||
if (!sourceItem) return zones;
|
||||
|
||||
const nextZones: HeaderZones = {
|
||||
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
};
|
||||
const targetItems = nextZones[targetZone];
|
||||
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
|
||||
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
|
||||
return nextZones;
|
||||
};
|
||||
|
||||
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
|
||||
if (id === null) return undefined;
|
||||
return items.find((item) => getHeaderItemKey(item) === id);
|
||||
};
|
||||
|
||||
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
|
||||
if (itemId === "search") return IconSearch;
|
||||
if (itemId === "home") return IconHome;
|
||||
if (itemId === "boardSwitcher") return IconReplace;
|
||||
if (itemId === "assistant") return IconRobot;
|
||||
if (itemId === "docker") return IconBrandDocker;
|
||||
if (itemId === "boardEdit") return IconPencil;
|
||||
if (itemId === "boardSettings") return IconSettings;
|
||||
if (itemId === "settings") return IconSettings;
|
||||
if (itemId === "themeToggle") return IconSunMoon;
|
||||
if (itemId === "user") return IconUserCircle;
|
||||
return IconLayoutDashboard;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -8,7 +8,6 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
@@ -38,7 +37,7 @@ export async function generateMetadata(props: Props) {
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
title: createMetaTitle(t("metaTitle", { username: user.name! })),
|
||||
title: t("metaTitle", { username: user.name! }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +57,7 @@ export default async function EditUserPage(props: Props) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
const isSelf = session?.user.id === user.id;
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
@@ -71,23 +70,18 @@ export default async function EditUserPage(props: Props) {
|
||||
</Alert>
|
||||
)}
|
||||
<Title>{tGeneral("title")}</Title>
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ base: "100%", lg: 260 }}>
|
||||
<UserProfileAvatarForm user={user} />
|
||||
</Box>
|
||||
</Group>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
preview: board.preview,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
profileAvatar={<UserProfileAvatarForm user={user} />}
|
||||
/>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
|
||||
@@ -8,7 +8,6 @@ import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -22,7 +21,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.user.create");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserListComponent } from "./_components/user-list";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -20,7 +19,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.user.list");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
title: t("metaTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
assistantProviderRequiresApiKey,
|
||||
assistantReasoningModes,
|
||||
} from "@homarr/definitions";
|
||||
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { fallbackLocale, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
@@ -370,7 +375,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
|
||||
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.text();
|
||||
@@ -414,7 +419,11 @@ export async function POST(request: Request) {
|
||||
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
const requestedLocale = (await cookies()).get(localeCookieKey)?.value;
|
||||
let locale: SupportedLanguage = fallbackLocale;
|
||||
if (requestedLocale && isLocaleSupported(requestedLocale)) locale = requestedLocale;
|
||||
const t = await getI18n({ locale, namespace: "assistant.unavailable" });
|
||||
return Response.json({ error: t("notConfigured") }, { status: 503 });
|
||||
}
|
||||
|
||||
let homarrProviderToken: string | null | undefined;
|
||||
|
||||
@@ -1,39 +1,59 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
const branding = serverSettings.branding;
|
||||
const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl;
|
||||
const icons: NonNullable<MetadataRoute.Manifest["icons"]> = [
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
];
|
||||
|
||||
if (appIcon) {
|
||||
icons.unshift({
|
||||
src: appIcon,
|
||||
sizes: "any",
|
||||
purpose: "any",
|
||||
});
|
||||
} else {
|
||||
icons.unshift(
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Homarr",
|
||||
short_name: "Homarr",
|
||||
name: branding.appName,
|
||||
short_name: branding.appName,
|
||||
description: "Your dashboard for managing your server.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#fff",
|
||||
theme_color: "#fff",
|
||||
icons: [
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
theme_color: branding.primaryColor,
|
||||
icons,
|
||||
};
|
||||
}
|
||||
@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
|
||||
|
||||
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
|
||||
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
|
||||
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||
};
|
||||
|
||||
export const buildAssistantConversationMarkdown = ({
|
||||
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
|
||||
const header = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||
"",
|
||||
`- Conversation ID: \`${thread.id}\``,
|
||||
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
|
||||
|
||||
@@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() =>
|
||||
|
||||
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
||||
useRegisterAssistantSpotlightPlaceholder(description);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 15% 10%,
|
||||
linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
||||
transparent 35%
|
||||
transparent 55%
|
||||
),
|
||||
var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||
export const BoardLayoutThumbnail = ({
|
||||
preview,
|
||||
label,
|
||||
fitFullLayout = false,
|
||||
className,
|
||||
}: BoardLayoutThumbnailProps) => {
|
||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
@@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
});
|
||||
if (lanes.length === 0) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
@@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={classes.canvas} role="img" aria-label={label}>
|
||||
<Box className={canvasClassName} role="img" aria-label={label}>
|
||||
<div
|
||||
className={classes.lanes}
|
||||
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
||||
|
||||
@@ -36,8 +36,14 @@ import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
|
||||
export interface BoardSwitcherControls {
|
||||
open: () => void;
|
||||
preload: () => void;
|
||||
hotkey: string;
|
||||
}
|
||||
|
||||
interface BoardSwitcherProps {
|
||||
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
||||
children: (controls: BoardSwitcherControls) => ReactNode;
|
||||
}
|
||||
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
|
||||
@@ -334,6 +334,7 @@ const LoadedBoardItemContent = ({
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
displayScale={boardCanvasScale}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
@@ -436,6 +437,7 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
displayScale: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
displayMode: "compact" | "advanced";
|
||||
|
||||
@@ -222,7 +222,9 @@ export const WidgetContextMenu = ({
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||
|
||||
|
||||
@@ -30,11 +30,15 @@
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||
|
||||
@@ -24,6 +24,7 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
|
||||
name: innerProps.previousName,
|
||||
});
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
void utils.board.getAllBoards.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -138,9 +138,56 @@ export const SectionGrid = ({
|
||||
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
|
||||
const logicalWidth = getLogicalGridSize(columnCount);
|
||||
const logicalHeight = getLogicalGridSize(rowCount);
|
||||
const viewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
// A container's own visible card is inset from its allocated board cell by the board's
|
||||
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
|
||||
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
|
||||
// any widget, so it gets that same gap). A widget's content just fills whatever size its
|
||||
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
||||
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
||||
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
|
||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
|
||||
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
|
||||
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
|
||||
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
|
||||
// corrected to compensate so icon/text sizing inside the container isn't affected.
|
||||
const fullGridWidth = getLogicalGridSize(columnCount);
|
||||
const fullGridHeight = getLogicalGridSize(rowCount);
|
||||
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
|
||||
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
|
||||
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
|
||||
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
|
||||
// describe the real visible card size in both cases (they equal the non-scrollable values when
|
||||
// rowCount and viewportRowCount are the same), so use those instead.
|
||||
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
|
||||
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
|
||||
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
|
||||
// be left with a small amount of unfilled slack when width/height need different ratios to
|
||||
// exactly fit - centered below so it's even on both sides rather than left-aligned.
|
||||
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
|
||||
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
|
||||
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
|
||||
// zero or negative - combinedUiScale divides by this value, and this value is also used as
|
||||
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
|
||||
const containerContentScale =
|
||||
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
|
||||
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
|
||||
: 1;
|
||||
// Compute the combined value in JS rather than a CSS calc() referencing the existing
|
||||
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
|
||||
// sequential/temporal order the way normal variables do, so any calc() on this element that
|
||||
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
|
||||
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
|
||||
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
|
||||
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
|
||||
// A collapsed container's compact coordinates are display-only. Its own
|
||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||
@@ -249,7 +296,15 @@ export const SectionGrid = ({
|
||||
>
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
style={{ width: logicalWidth, height: logicalHeight }}
|
||||
style={
|
||||
{
|
||||
width: fullGridWidth,
|
||||
height: fullGridHeight,
|
||||
zoom: containerContentScale,
|
||||
margin: containerContentScale < 1 ? "0 auto" : undefined,
|
||||
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
|
||||
} as CSSProperties
|
||||
}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
|
||||
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(gridDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const appShellHeaderHeight = 60;
|
||||
export const appShellLogoHeight = appShellHeaderHeight - 8;
|
||||
@@ -1,49 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
import { LazySpotlight } from "./header/lazy-spotlight";
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { appShellLogoHeight } from "./constants";
|
||||
import { ConfigurableHeader } from "./header/configurable-header";
|
||||
import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
logoWithTitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
boardEditAction?: ReactNode;
|
||||
boardSettingsAction?: ReactNode;
|
||||
hasNavigation?: boolean;
|
||||
}
|
||||
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
export const MainHeader = async ({
|
||||
logo,
|
||||
logoWithTitle,
|
||||
actions,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
hasNavigation = true,
|
||||
}: Props) => {
|
||||
const session = await auth();
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
|
||||
return (
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
style={{ overflowX: "hidden" }}
|
||||
data-advanced-focus-background
|
||||
data-app-shell-header
|
||||
>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
<UnstyledButton component={Link} href="/">
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
<ConfigurableHeader
|
||||
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
|
||||
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
|
||||
actions={actions}
|
||||
boardEditAction={boardEditAction}
|
||||
boardSettingsAction={boardSettingsAction}
|
||||
hasNavigation={hasNavigation}
|
||||
avatar={<CurrentUserAvatar size="md" />}
|
||||
userId={session?.user.id ?? null}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
.header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-inline: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding-inline-end: rem(10px);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.zone::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zone[data-zone="left"] {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.zone[data-zone="center"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zone[data-zone="right"] {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.search[data-display="icon"] {
|
||||
width: 2.25rem;
|
||||
flex: 0 0 2.25rem;
|
||||
}
|
||||
|
||||
.search > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.zone [data-tour-target="board-search"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.contextActions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.floatingControls {
|
||||
position: fixed;
|
||||
z-index: var(--homarr-z-index-board-header);
|
||||
top: max(rem(10px), env(safe-area-inset-top));
|
||||
right: max(rem(10px), env(safe-area-inset-right));
|
||||
padding: rem(8px);
|
||||
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
|
||||
box-shadow: 0 rem(12px) rem(36px) rgb(0 0 0 / 20%);
|
||||
backdrop-filter: blur(rem(20px)) saturate(140%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(rem(-8px)) scale(0.98);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.floatingControls[data-visible="true"],
|
||||
.floatingControls:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.floatingControls button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.floatingControlsCorner {
|
||||
position: fixed;
|
||||
z-index: calc(var(--homarr-z-index-board-header) - 1);
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: rem(112px);
|
||||
height: rem(112px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floatingControlsCorner[data-active="true"] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.content {
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.zone {
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.floatingControls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.floatingControlsCorner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floatingControls {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AppShellHeader,
|
||||
Avatar,
|
||||
Group,
|
||||
Indicator,
|
||||
Loader,
|
||||
Tooltip,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { HeaderItem } from "@homarr/validation/user";
|
||||
import { getHeaderItemKey, getHeaderItems } from "@homarr/validation/user";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
import { BoardSwitcher } from "~/components/board/board-switcher";
|
||||
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||
import { ClientBurger } from "./burger";
|
||||
import { HeaderButton } from "./button";
|
||||
import { DockerQuickAccessModal } from "./docker-quick-access-modal";
|
||||
import { HeaderLogo } from "./header-logo";
|
||||
import { LazySpotlight } from "./lazy-spotlight";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./search";
|
||||
import { TourTarget } from "./tour-target";
|
||||
import { UserButtonClient } from "./user-client";
|
||||
import classes from "./configurable-header.module.css";
|
||||
|
||||
interface ConfigurableHeaderProps {
|
||||
logo: ReactNode;
|
||||
logoWithTitle: ReactNode;
|
||||
actions?: ReactNode;
|
||||
boardEditAction?: ReactNode;
|
||||
boardSettingsAction?: ReactNode;
|
||||
hasNavigation: boolean;
|
||||
avatar: ReactNode;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
}
|
||||
|
||||
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
|
||||
|
||||
const floatingControlsIntroDurationMs = 5_000;
|
||||
const floatingControlsDismissDelayMs = 900;
|
||||
|
||||
export const ConfigurableHeader = ({
|
||||
logo,
|
||||
logoWithTitle,
|
||||
actions,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
hasNavigation,
|
||||
avatar,
|
||||
userId,
|
||||
isAdmin,
|
||||
isDockerEnabled,
|
||||
}: ConfigurableHeaderProps) => {
|
||||
const { headerPreferences } = useSettings();
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
const t = useI18n("management.page.user.setting.general.header");
|
||||
const allHeaderItems = getHeaderItems(headerPreferences.zones);
|
||||
const hasBoardShortcuts = allHeaderItems.some((item) => item.type === "board");
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, {
|
||||
enabled: hasBoardShortcuts,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
|
||||
return (
|
||||
<BoardSwitcher>
|
||||
{(boardSwitcher) => {
|
||||
const renderItems = (items: HeaderItem[]) =>
|
||||
items.map((item) => (
|
||||
<HeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
item={item}
|
||||
logo={logo}
|
||||
logoWithTitle={logoWithTitle}
|
||||
boardEditAction={boardEditAction}
|
||||
boardSettingsAction={boardSettingsAction}
|
||||
avatar={avatar}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||
searchDisplay={headerPreferences.searchDisplay}
|
||||
logoDisplay={headerPreferences.logoDisplay}
|
||||
boardSwitcher={boardSwitcher}
|
||||
assistant={assistant}
|
||||
openDockerModal={openDockerModal}
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
label={(key) => t(`items.${key}` as never)}
|
||||
boardUnavailableLabel={t("items.boardUnavailable")}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerPreferences.visible ? (
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
className={classes.header}
|
||||
data-advanced-focus-background
|
||||
data-app-shell-header
|
||||
>
|
||||
<div className={classes.content}>
|
||||
<div className={classes.zone} data-zone="left">
|
||||
{hasNavigation ? <ClientBurger /> : null}
|
||||
{renderItems(headerPreferences.zones.left)}
|
||||
</div>
|
||||
<div className={classes.zone} data-zone="center">
|
||||
{renderItems(headerPreferences.zones.center)}
|
||||
</div>
|
||||
<div className={classes.zone} data-zone="right">
|
||||
{renderItems(headerPreferences.zones.right)}
|
||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||
</div>
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
) : (
|
||||
<FloatingHeaderControls>
|
||||
{hasNavigation ? <ClientBurger /> : null}
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</FloatingHeaderControls>
|
||||
)}
|
||||
<LazySpotlight />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</BoardSwitcher>
|
||||
);
|
||||
};
|
||||
|
||||
const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
|
||||
const [isIntroComplete, setIsIntroComplete] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const isInteractingRef = useRef(false);
|
||||
const dismissTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const clearDismissTimeout = () => {
|
||||
if (dismissTimeoutRef.current === null) return;
|
||||
window.clearTimeout(dismissTimeoutRef.current);
|
||||
dismissTimeoutRef.current = null;
|
||||
};
|
||||
|
||||
const dismissSoon = () => {
|
||||
if (!isIntroComplete) return;
|
||||
clearDismissTimeout();
|
||||
dismissTimeoutRef.current = window.setTimeout(() => {
|
||||
if (!isInteractingRef.current) setIsVisible(false);
|
||||
}, floatingControlsDismissDelayMs);
|
||||
};
|
||||
|
||||
const revealFromCorner = () => {
|
||||
if (!isIntroComplete) return;
|
||||
clearDismissTimeout();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const startInteraction = () => {
|
||||
isInteractingRef.current = true;
|
||||
clearDismissTimeout();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const endInteraction = () => {
|
||||
isInteractingRef.current = false;
|
||||
dismissSoon();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const introTimeout = window.setTimeout(() => {
|
||||
setIsIntroComplete(true);
|
||||
if (!isInteractingRef.current) setIsVisible(false);
|
||||
}, floatingControlsIntroDurationMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(introTimeout);
|
||||
if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classes.floatingControlsCorner}
|
||||
data-active={isIntroComplete || undefined}
|
||||
aria-hidden
|
||||
onPointerEnter={revealFromCorner}
|
||||
onPointerMove={revealFromCorner}
|
||||
onPointerDown={revealFromCorner}
|
||||
onPointerLeave={dismissSoon}
|
||||
/>
|
||||
<Group
|
||||
className={classes.floatingControls}
|
||||
data-visible={isVisible || undefined}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
onPointerEnter={startInteraction}
|
||||
onPointerLeave={endInteraction}
|
||||
onFocusCapture={startInteraction}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||
endInteraction();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeaderItemProps {
|
||||
item: HeaderItem;
|
||||
logo: ReactNode;
|
||||
logoWithTitle: ReactNode;
|
||||
boardEditAction: ReactNode;
|
||||
boardSettingsAction: ReactNode;
|
||||
avatar: ReactNode;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
board: HeaderBoard | undefined;
|
||||
searchDisplay: "input" | "icon";
|
||||
logoDisplay: "logo" | "logoAndText";
|
||||
boardSwitcher: BoardSwitcherControls;
|
||||
assistant: ReturnType<typeof useOptionalHomarrAssistant>;
|
||||
openDockerModal: () => void;
|
||||
toggleColorScheme: () => void;
|
||||
label: (key: string) => string;
|
||||
boardUnavailableLabel: string;
|
||||
}
|
||||
|
||||
const HeaderItem = ({
|
||||
item,
|
||||
logo,
|
||||
logoWithTitle,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
avatar,
|
||||
userId,
|
||||
isAdmin,
|
||||
isDockerEnabled,
|
||||
board,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
boardSwitcher,
|
||||
assistant,
|
||||
openDockerModal,
|
||||
toggleColorScheme,
|
||||
label,
|
||||
boardUnavailableLabel,
|
||||
}: HeaderItemProps) => {
|
||||
if (item.type === "board") {
|
||||
if (!board) {
|
||||
return (
|
||||
<Tooltip label={boardUnavailableLabel}>
|
||||
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
||||
<IconLayoutDashboard size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={board.name}>
|
||||
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
|
||||
<Avatar src={board.logoImageUrl} size={22} radius="sm">
|
||||
<IconLayoutDashboard size={17} stroke={1.5} />
|
||||
</Avatar>
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "logo") {
|
||||
return (
|
||||
<HeaderLogo
|
||||
display={logoDisplay}
|
||||
logo={logo}
|
||||
logoWithTitle={logoWithTitle}
|
||||
label={label("logo")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "boardEdit") return boardEditAction;
|
||||
if (item.id === "boardSettings") return boardSettingsAction;
|
||||
|
||||
if (item.id === "search") {
|
||||
return (
|
||||
<TourTarget id="board-search">
|
||||
<div className={classes.search} data-display={searchDisplay}>
|
||||
{searchDisplay === "input" ? (
|
||||
<>
|
||||
<DesktopSearchInput />
|
||||
<MobileSearchButton />
|
||||
</>
|
||||
) : (
|
||||
<MobileSearchButton alwaysVisible />
|
||||
)}
|
||||
</div>
|
||||
</TourTarget>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "home") {
|
||||
return (
|
||||
<Tooltip label={label("home")}>
|
||||
<HeaderButton href="/boards" aria-label={label("home")}>
|
||||
<IconHome size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "boardSwitcher") {
|
||||
return (
|
||||
<Tooltip label={label("boardSwitcher")}>
|
||||
<HeaderButton
|
||||
aria-label={label("boardSwitcher")}
|
||||
onClick={boardSwitcher.open}
|
||||
onFocus={boardSwitcher.preload}
|
||||
onPointerEnter={boardSwitcher.preload}
|
||||
>
|
||||
<IconReplace size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "assistant") {
|
||||
if (!assistant?.enabled) return null;
|
||||
return (
|
||||
<Tooltip label={label("assistant")}>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant.isRunning && assistant.unreadCount === 0}
|
||||
color="red"
|
||||
size={16}
|
||||
label={assistant.isRunning ? <Loader type="bars" color="white" size={8} /> : assistant.unreadCount}
|
||||
>
|
||||
<HeaderButton aria-label={label("assistant")} onClick={assistant.open}>
|
||||
<IconRobot size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Indicator>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "docker") {
|
||||
if (!isDockerEnabled) return null;
|
||||
return (
|
||||
<Tooltip label={invariantTechnicalLabels.docker}>
|
||||
<HeaderButton aria-label={invariantTechnicalLabels.docker} onClick={openDockerModal}>
|
||||
<IconBrandDocker size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "settings") {
|
||||
const href = userId ? `/manage/users/${userId}/general` : "/auth/login";
|
||||
return (
|
||||
<Tooltip label={label("settings")}>
|
||||
<HeaderButton href={href} aria-label={label("settings")}>
|
||||
<IconSettings size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "themeToggle") {
|
||||
return (
|
||||
<Tooltip label={label("themeToggle")}>
|
||||
<HeaderButton aria-label={label("themeToggle")} onClick={toggleColorScheme}>
|
||||
<IconSunMoon size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.root[data-display="logo"] {
|
||||
justify-content: center;
|
||||
width: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-display="logo"] img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import classes from "./header-logo.module.css";
|
||||
|
||||
interface HeaderLogoProps {
|
||||
display: "logo" | "logoAndText";
|
||||
logo: ReactNode;
|
||||
logoWithTitle: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
|
||||
let content = logo;
|
||||
if (display === "logoAndText") content = logoWithTitle;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
href="/"
|
||||
className={classes.root}
|
||||
data-display={display}
|
||||
aria-label={label}
|
||||
>
|
||||
{content}
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
@@ -28,12 +28,14 @@ export const DesktopSearchInput = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<HeaderButton
|
||||
data-homarr-dev-benchmark-interaction="search"
|
||||
onClick={() => openSpotlight()}
|
||||
className={classes.mobileSearch}
|
||||
className={alwaysVisible ? undefined : classes.mobileSearch}
|
||||
aria-label={t("search.placeholder")}
|
||||
>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
|
||||
@@ -8,18 +8,22 @@ import { useHotkeys } from "@mantine/hooks";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
boardSwitcher: BoardSwitcherControls;
|
||||
}
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -45,8 +49,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
|
||||
return (
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
|
||||
<UnstyledButton aria-label={t("open")}>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
{avatar}
|
||||
</UpdateIndicator>
|
||||
|
||||
@@ -2,13 +2,19 @@ import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||
import { UserButtonClient } from "./user-client";
|
||||
|
||||
export const UserButton = async () => {
|
||||
export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
|
||||
const session = await auth();
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
return (
|
||||
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
||||
<UserButtonClient
|
||||
avatar={<CurrentUserAvatar size="md" />}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
|
||||
import { homarrLogoPath } from "./constants";
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
|
||||
@@ -12,8 +13,9 @@ interface LogoProps {
|
||||
|
||||
const useImageOptions = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
return {
|
||||
src: board.logoImageUrl ?? homarrLogoPath,
|
||||
src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
|
||||
alt: "Board logo",
|
||||
shouldUseNextImage: false,
|
||||
};
|
||||
@@ -31,12 +33,13 @@ interface CommonLogoWithTitleProps {
|
||||
|
||||
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
const imageOptions = useImageOptions();
|
||||
return (
|
||||
<LogoWithTitle
|
||||
size={size}
|
||||
hideTitleOnMobile={hideTitleOnMobile}
|
||||
title={board.pageTitle ?? homarrPageTitle}
|
||||
title={board.pageTitle ?? branding.appName}
|
||||
image={imageOptions}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const homarrLogoPath = "/logo/logo.png";
|
||||
@@ -1,25 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
import { homarrLogoPath } from "./constants";
|
||||
|
||||
interface LogoProps {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const homarrLogoPath = "/logo/logo.png";
|
||||
export const homarrPageTitle = "Homarr";
|
||||
|
||||
const imageOptions = {
|
||||
src: homarrLogoPath,
|
||||
alt: "Homarr logo",
|
||||
shouldUseNextImage: true,
|
||||
const useBrandLogo = () => {
|
||||
const { branding } = useSettings();
|
||||
const configuredLogoUrl = branding.logoImageUrl?.trim();
|
||||
return {
|
||||
title: branding.appName,
|
||||
image: {
|
||||
src: configuredLogoUrl || homarrLogoPath,
|
||||
alt: `${branding.appName} logo`,
|
||||
shouldUseNextImage: !configuredLogoUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />;
|
||||
export const HomarrLogo = ({ size }: LogoProps) => {
|
||||
const brand = useBrandLogo();
|
||||
return <Logo size={size} {...brand.image} />;
|
||||
};
|
||||
|
||||
interface CommonLogoWithTitleProps {
|
||||
size: LogoWithTitleProps["size"];
|
||||
hideTitleOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
||||
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />;
|
||||
export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
||||
const brand = useBrandLogo();
|
||||
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
|
||||
};
|
||||
@@ -9,14 +9,18 @@ interface LogoProps {
|
||||
shouldUseNextImage?: boolean;
|
||||
}
|
||||
|
||||
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
|
||||
shouldUseNextImage ? (
|
||||
<Image className="logo" src={src} alt={alt} width={size} height={size} />
|
||||
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
|
||||
const normalizedSrc = src.trim();
|
||||
if (normalizedSrc.length === 0) return null;
|
||||
|
||||
return shouldUseNextImage ? (
|
||||
<Image className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
|
||||
) : (
|
||||
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="logo" src={src} alt={alt} width={size} height={size} />
|
||||
<img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
|
||||
);
|
||||
};
|
||||
|
||||
const logoWithTitleSizes = {
|
||||
lg: { logoSize: 48, titleOrder: 1 },
|
||||
|
||||
@@ -15,7 +15,17 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||
|
||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||
const pathMatches = (pathname: string, href: string, exact = false) => {
|
||||
if (pathname === href) return true;
|
||||
if (exact) return false;
|
||||
return pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
const getMostSpecificMatchingHref = (pathname: string, items: NavigationLinkHref[]) =>
|
||||
items
|
||||
.filter((item) => pathMatches(pathname, item.href, item.exact))
|
||||
.toSorted((first, second) => second.href.length - first.href.length)
|
||||
.at(0)?.href;
|
||||
|
||||
const useClientPathname = () => {
|
||||
const pathname = usePathname();
|
||||
@@ -31,6 +41,7 @@ const useClientPathname = () => {
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const { pathname, isClient } = useClientPathname();
|
||||
const tourId = props["data-onboarding-tour-id"];
|
||||
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
|
||||
const link = props.external ? (
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
@@ -39,7 +50,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={isClient && pathMatches(pathname, props.href)}
|
||||
active={isActive}
|
||||
/>
|
||||
);
|
||||
return withOptionalTourTarget(tourId, link);
|
||||
@@ -47,11 +58,18 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const { pathname, isClient } = useClientPathname();
|
||||
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
|
||||
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
|
||||
const isActive = isClient && activeItemHref !== undefined;
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) setOpened(true);
|
||||
}, [isActive]);
|
||||
|
||||
const nav = (
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
<NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
@@ -67,6 +85,8 @@ interface CommonNavigationLinkProps {
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
exact?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||
items: NavigationLinkHref[];
|
||||
|
||||
@@ -72,6 +72,7 @@ interface CommonNavigationLinkProps {
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
exact?: boolean;
|
||||
}
|
||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||
items: NavigationLinkHref[];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShell } from "@mantine/core";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { useOptionalBackgroundProps } from "./background";
|
||||
import { appShellHeaderHeight } from "./constants";
|
||||
@@ -20,11 +21,13 @@ export const ClientShell = ({
|
||||
}: PropsWithChildren<ClientShellProps>) => {
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
const backgroundProps = useOptionalBackgroundProps();
|
||||
const { headerPreferences } = useSettings();
|
||||
const isHeaderVisible = hasHeader && headerPreferences.visible;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
|
||||
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
|
||||
@@ -8,10 +8,9 @@ import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||
import type { FloatingPosition } from "@mantine/core";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
|
||||
interface TourShellProps extends PropsWithChildren {
|
||||
steps: OnboardingTourStep[];
|
||||
started: boolean;
|
||||
@@ -79,6 +78,7 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
||||
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||
const tCommon = useI18n("common.action");
|
||||
const tTour = useI18n("onboardingTour");
|
||||
const { branding } = useSettings();
|
||||
const router = useRouter();
|
||||
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -145,7 +145,13 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
|
||||
</Group>
|
||||
{stepIndexFromController(controller) === 0 && (
|
||||
<Center py="xs">
|
||||
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
||||
<Image
|
||||
src={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
alt={`${branding.appName} logo`}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||
import { BoardSwitcher } from "./board/board-switcher";
|
||||
import type { BoardSwitcherControls } from "./board/board-switcher";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
@@ -33,6 +33,7 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
boardSwitcher: BoardSwitcherControls;
|
||||
}
|
||||
|
||||
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
@@ -41,7 +42,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
|
||||
.join(" + ");
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => {
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
@@ -59,111 +60,107 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
||||
}, [logoutUrl]);
|
||||
|
||||
return (
|
||||
<BoardSwitcher>
|
||||
{({ open, preload, hotkey }) => (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={open}
|
||||
onFocus={preload}
|
||||
onPointerEnter={preload}
|
||||
>
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={boardSwitcher.open}
|
||||
onFocus={boardSwitcher.preload}
|
||||
onPointerEnter={boardSwitcher.preload}
|
||||
>
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
)}
|
||||
</BoardSwitcher>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user