Compare commits

..
Author SHA1 Message Date
ajnart 7e420bc087 refactor: share mobile breakpoint handling 2026-08-27 09:15:33 +02:00
ajnart 322d4a7fc4 fix: keep mobile header logo condensed 2026-08-27 07:26:34 +02:00
ajnart 63e1bc7f6c fix: improve mobile header and board menus 2026-08-27 07:24:40 +02:00
188 changed files with 3561 additions and 4803 deletions

No files matched your search

-3
View File
@@ -29,9 +29,6 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
You can obtain API keys from the "Authentication" tab at the top of the page.
API keys will not expire and can be used indefinitely.
When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the
creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation
instead of opening a separate dialog.
### API Key Format
+4 -8
View File
@@ -34,11 +34,9 @@ arrow keys to move through the results and press `Enter` to open the selected bo
## Create a new board
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
When creating a board, the availability of the board name will be checked for you.
The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that
board, and create the copy without navigating to a separate page.
![](./img/create-board-modal.png)
### Name
@@ -58,9 +56,6 @@ This will enable public access for everyone - regardless of whether they are log
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the
same menu item. No separate confirmation dialog is opened.
## Board settings
You navigate to the board settings by clicking on these three dots:
@@ -410,7 +405,8 @@ nor the integrations the user does not have access to.
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
Duplicating a board that contains Custom Widgets also requires an administrator.
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
The name form expands below that board card and can be cancelled without leaving the list.
![](./img/duplicate-board-modal.png)
## Setting boards as your home board
@@ -30,12 +30,15 @@ Navigate to `Management` > `Tools` > `Certificates`.
## Add a certificate
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
To add a certificate click on the "Add certificate" button.
![](./img/add-button.png)
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
leaving the page.
Then select the certificate file you want to add.
![](./img/add-modal.png)
And click the "Add" button.
## Delete a certificate
@@ -43,7 +46,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
![](./img/card.png)
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
Then confirm the deletion.
## Managing it through file system
@@ -114,8 +114,7 @@ The install flow always links to the corresponding public Workshop entry under t
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
performs definitive validation again at install time.
+4 -6
View File
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
### Creating a group
Click on the button `New group` to create a new one.
The group-name form expands above the group list and can be closed without leaving the page.
Homarr will prompt you for a group name.
### Managing a group
@@ -259,8 +259,7 @@ An invitation link can be used indefinitely.
### Creating an invitation
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
dialog or navigating to another page:
Click on the button:
![](./img/create-invitation-button.png)
@@ -270,12 +269,12 @@ The default is 4 hours in the future.
![](./img/invitation-expiry-date.png)
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
![](./img/create-invitation-confirmation.png)
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
Use **Copy** to copy the URL, then close the section when you are finished.
Clicking the `Copy & close` button will copy the URL to your clipboard.
### Self-onboarding
@@ -294,4 +293,3 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
The link will become invalid.
If the invitation expires, the link will also expire.
The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog.
+1 -1
View File
@@ -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. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
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.
It also supports a status check to indicate whether Homarr can reach the application or website.
### Screenshots
@@ -23,8 +23,6 @@ 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. 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.
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.
### Screenshots
+1 -1
View File
@@ -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. Compact resource badges and footer details remain visible without height-based hiding.
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.
### 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, with its familiar textual status control retained at compact heights. 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, while cards and controls adapt to the widget&apos;s width and height. 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. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
This widget displays the resource usage of your Docker containers and allows you to manage them.
:::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.
+6 -5
View File
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
### Features
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
- **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
- **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,10 +72,11 @@ import filterPanelImg from "./img/filter-panel.png";
:::note
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.
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.
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,8 +16,6 @@ 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
![Firewall Widget](img/firewall_widget.png)
@@ -15,8 +15,6 @@ 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,8 +14,6 @@ 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. Compact rows retain their poster, full release date and time, and configured metadata.
Provides a list of newly added media or upcoming releases from your media server.
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. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
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. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
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.
### 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. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
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.
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. 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.
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).
### 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 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.
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.
### Screenshots
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
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.
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.
### 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.
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.
<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. Compact mode presents them as a vertical list of padded notification cards.
The notifications widget allows you to display notifications from various integrations in a unified view.
### 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. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
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.
### Supported Integrations
+2 -2
View File
@@ -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. Compact mode keeps repositories in the familiar full-width stack.
The releases widget enables you to track a list of repositories and see their respective latest release.
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 in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
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.
<WidgetInteractionGuide />
+1 -1
View File
@@ -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. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
### 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. Compact mode presents the friendly name first and its state beneath it.
The entity state enables you to display the current state of an entity.
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 every configured overview section in its familiar card layout 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 the responsive aggregate overview 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. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
### 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. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
Displays CPU, RAM and network of your host in real time
### Screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
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.
The Timetable widget provides real-time departure times for a selected station.
### Screenshots
+1 -1
View File
@@ -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. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
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.
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.
+1 -1
View File
@@ -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 every configured protocol card and entry-point label visible 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 the responsive aggregate overview 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.
+1 -1
View File
@@ -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. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
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.
### Supported Integrations
+1 -1
View File
@@ -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. Compact mode shows every configured ring and statistic without dropping them based on widget height.
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
@@ -1,16 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Box, Button, Group, TextInput } from "@mantine/core";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useZodForm } from "@homarr/form";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { boardRenameSchema } from "@homarr/validation/board";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
@@ -18,70 +17,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const utils = clientApi.useUtils();
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: board.name,
},
});
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
useEffect(() => {
if (isRenaming) renameInputRef.current?.focus();
}, [isRenaming]);
const handleRename = renameForm.onSubmit((values) => {
renameBoard(
{ id: board.id, name: values.name },
{
onSuccess() {
router.push(`/boards/${values.name}/settings`);
},
},
);
});
const cancelRename = () => {
renameForm.reset();
setIsRenaming(false);
};
const handleVisibilityChange = () => {
changeVisibility(
{
const onRenameClick = useCallback(
() =>
openModal({
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
};
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const handleDelete = () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
},
);
};
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
return (
<DangerZoneRoot>
@@ -89,28 +88,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
action={
isRenaming ? (
<Box component="form" onSubmit={handleRename}>
<Group wrap="wrap" justify="end">
<TextInput
ref={renameInputRef}
aria-label={t("section.dangerZone.action.rename.label")}
size="sm"
{...renameForm.getInputProps("name")}
/>
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
{tCommonAction("cancel")}
</Button>
<Button type="submit" color="red" loading={isRenamePending}>
{tCommonAction("confirm")}
</Button>
</Group>
</Box>
) : (
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
{t("section.dangerZone.action.rename.button")}
</Button>
)
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
}
/>
{!hideVisibility && (
@@ -118,15 +98,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<InlineConfirmButton
variant="subtle"
color="red"
loading={isChangeVisibilityPending}
onConfirm={handleVisibilityChange}
confirmLabel={tCommonAction("confirm")}
>
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</InlineConfirmButton>
</Button>
}
/>
)}
@@ -134,15 +108,9 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<InlineConfirmButton
variant="subtle"
color="red"
loading={isDeletePending}
onConfirm={handleDelete}
confirmLabel={tCommonAction("confirm")}
>
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{tCommonAction("delete")}
</InlineConfirmButton>
</Button>
}
/>
</DangerZoneRoot>
@@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
@@ -46,6 +46,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
@@ -83,20 +84,29 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
}
};
const handleReset = async (layout: FormValues["layouts"][number]) => {
setResettingLayoutId(layout.id);
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
const confirmReset = (layout: FormValues["layouts"][number]) => {
openConfirmModal({
title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
children: tBoard("setting.section.layout.reset.confirm.message"),
confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" },
onConfirm() {
setResettingLayoutId(layout.id);
void (async () => {
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
})();
},
});
};
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
@@ -226,17 +236,16 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
<Button
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
onClick={() => confirmReset(layout)}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
</Button>
)}
{layout.role === "custom" && (
<Button
@@ -1,12 +1,13 @@
"use client";
import { useCallback } from "react";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -17,41 +18,48 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onConfirm = useCallback(() => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: app.name,
}),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
},
);
}, [app.id, mutate, t, tCommon]);
});
}, [app, mutate, t, tCommon, openConfirmModal]);
return (
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -8,8 +8,10 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -23,10 +25,9 @@ interface BoardCardMenuDropdownProps {
RouterOutputs["board"]["getManageOverview"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
onDuplicate: () => void;
}
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
@@ -34,6 +35,9 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
@@ -52,10 +56,20 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
},
});
const handleDeletion = useCallback(
() => deleteBoardMutation.mutateAsync({ id: board.id }),
[board.id, deleteBoardMutation],
);
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tCommon("action.deleteNamed", { name: board.name }),
children: t("delete.confirm.description", {
name: board.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await deleteBoardMutation.mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -65,6 +79,15 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -74,7 +97,7 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")}
</Menu.Item>
)}
@@ -94,15 +117,14 @@ export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropd
<>
<Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<InlineConfirmMenuItem
<Menu.Item
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
onClick={handleDeletion}
disabled={deleteBoardMutation.isPending}
>
{tCommon("action.delete")}
</InlineConfirmMenuItem>
</Menu.Item>
</>
)}
</Menu.Dropdown>
@@ -1,133 +0,0 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Collapse,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
import { DuplicateBoardForm } from "./duplicate-board-form";
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<>
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown
board={board}
onDuplicate={() => {
setDuplicateFormKey((value) => value + 1);
setIsDuplicateOpen(true);
}}
/>
</Menu>
)}
</Group>
</CardSection>
</Card>
<Collapse expanded={isDuplicateOpen}>
<Card mt="sm" withBorder>
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
</Card>
</Collapse>
</>
);
};
@@ -1,55 +1,23 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
import { Button } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { BoardCreateForm } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n("management.page.board.action");
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsOpen(true);
}, [searchParams]);
const close = () => {
setIsOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
};
const toggle = () => {
if (isOpen) {
close();
return;
}
setFormKey((value) => value + 1);
setIsOpen(true);
};
const { openModal } = useModalAction(AddBoardModal);
return (
<Stack>
<Group justify="end">
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
{t("new.label")}
</Button>
</Group>
<Collapse expanded={isOpen}>
<Card withBorder>
<BoardCreateForm key={formKey} onCancel={close} />
</Card>
</Collapse>
</Stack>
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("new.label")}
</Button>
</ManageMobilePrimaryAction>
);
};
@@ -1,15 +1,38 @@
import { Grid, GridCol } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Grid,
GridCol,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import {
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCard } from "./_components/board-card";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
@@ -19,14 +42,24 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
<ManagePageLayout title={t("title")}>
{canCreateBoards && (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
)}
<ManagePageLayout
title={t("title")}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && (
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
<NoResults
icon={IconLayoutDashboard}
title={t("noResults.title")}
description={t("noResults.description")}
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
/>
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
@@ -59,3 +92,97 @@ export default async function ManageBoardsPage() {
</ManagePageLayout>
);
}
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group>
</CardSection>
</Card>
);
};
@@ -20,9 +20,10 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { downloadJson } from "~/components/custom-widgets/download";
@@ -39,7 +40,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
@@ -172,24 +173,30 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
const handleDelete = () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
openConfirmModal({
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
);
},
);
});
};
return (
@@ -256,15 +263,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</>
)}
{!widget.migrationRequired && (
<InlineConfirmMenuItem
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</InlineConfirmMenuItem>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
@@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopReportForm } from "~/components/workshop/workshop-report-form";
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
import { useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -213,13 +213,6 @@ export function WorkshopDetail({ id }: { id: string }) {
</Accordion.Item>
</Accordion>
<WorkshopReportForm
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
<ManageStickyFooter
secondary={
<>
@@ -248,8 +241,6 @@ export function WorkshopDetail({ id }: { id: string }) {
leftSection={<IconFlag size={16} />}
disabled={!session.user}
onClick={reportControls.open}
aria-expanded={reportOpened}
aria-controls="workshop-report-form"
>
{t("report")}
</Button>
@@ -268,6 +259,13 @@ export function WorkshopDetail({ id }: { id: string }) {
</Button>
</ManageStickyFooter>
</Stack>
<WorkshopReportModal
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
</ManagePageLayout>
);
}
@@ -6,12 +6,11 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, getMantineColor } from "@homarr/common";
import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
interface CertificateErrorDetailsProps {
@@ -24,71 +23,80 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false);
const [showUploadForm, setShowUploadForm] = useState(false);
const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } =
clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } =
clientApi.certificates.addCertificate.useMutation();
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = async () => {
const handleTrustHostname = () => {
const { hostname } = new URL(url);
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
openConfirmModal({
title: tError("certificate.hostnameMismatch.confirm.title"),
children: tError("certificate.hostnameMismatch.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
});
};
const handleTrustSelfSigned = async () => {
const handleTrustSelfSigned = () => {
const { hostname } = new URL(url);
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
openConfirmModal({
title: tError("certificate.selfSigned.confirm.title"),
children: tError("certificate.selfSigned.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
const formData = new FormData();
formData.append(
"file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
});
},
});
};
const description = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
let trustConfirmLabel = tError("certificate.selfSigned.confirm.title");
if (error.data.reason === "hostnameMismatch") {
trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title");
}
if (!isAdmin) {
return (
@@ -122,36 +130,30 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<InlineConfirmButton
<Button
type="button"
variant="default"
fullWidth
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
confirmLabel={trustConfirmLabel}
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
>
{tError("certificate.action.trust.label")}
</InlineConfirmButton>
</Button>
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Stack gap="sm">
{!showUploadForm && (
<Button variant="default" fullWidth onClick={() => setShowUploadForm(true)}>
{tError("certificate.action.upload.label")}
</Button>
)}
{showUploadForm && (
<Card withBorder>
<CertificateUploadForm
embedded
onCancel={() => setShowUploadForm(false)}
onSuccess={() => {
setShowRetryButton(true);
setShowUploadForm(false);
}}
/>
</Card>
)}
</Stack>
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
openUploadModal({
onSuccess() {
setShowRetryButton(true);
},
})
}
>
{tError("certificate.action.upload.label")}
</Button>
) : null}
</>
);
@@ -1,12 +1,13 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string };
@@ -15,42 +16,46 @@ interface DeleteIntegrationActionButtonProps {
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useI18n("integration.page.delete");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
const onConfirm = () =>
mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
return (
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={() => {
openConfirmModal({
title: t("title"),
children: t("message", integration),
onConfirm: () => {
void mutateAsync(
{ id: integration.id },
{
onSuccess: () => {
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
);
},
});
}}
aria-label={tCommon("action.deleteNamed", { name: integration.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -1,41 +1,40 @@
"use client";
import { Tooltip } from "@mantine/core";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface DeleteMediaProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number];
}
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
const { openConfirmModal } = useConfirmModal();
const t = useI18n("media");
const tCommon = useI18n("common");
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
const onConfirm = async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
const onClick = () => {
openConfirmModal({
title: t("action.delete.label"),
children: t.rich("action.delete.description", { bName: () => <b>{media.name}</b> }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({ id: media.id });
await revalidatePathActionAsync("/manage/medias");
},
});
};
return (
<Tooltip label={t("action.delete.label")} openDelay={500}>
<InlineConfirmActionIcon
color="red"
variant="subtle"
onConfirm={onConfirm}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.label")}
>
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
</Tooltip>
);
};
@@ -1,14 +1,15 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
@@ -17,41 +18,48 @@ interface SearchEngineDeleteButtonProps {
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useI18n("search.engine.page.delete");
const actionT = useI18n("common.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onConfirm = useCallback(() => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: searchEngine.name,
}),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
);
}, [searchEngine.id, mutate, t]);
});
}, [searchEngine, mutate, t, openConfirmModal]);
return (
<InlineConfirmActionIcon
confirmLabel={actionT("confirm")}
confirmationAriaLabel={actionT("confirm")}
onConfirm={onConfirm}
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -1,67 +1,34 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { NewApiKeyAlert } from "./new-api-key-alert";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
interface ApiKeysManagementProps {
apiKeys: RouterOutputs["apiKeys"]["getAll"];
onCreated: () => void;
}
type ApiKey = RouterOutputs["apiKeys"]["getAll"][number];
type ApiKeyCellProps = { row: MRT_Row<ApiKey> };
interface ApiKeysTableMeta {
handleDelete: (id: string) => Promise<void>;
confirmLabel: string;
deleteTitle: string;
isPendingDelete: boolean;
}
const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => (
<Group gap="xs">
<UserAvatar user={row.original.user} size="sm" />
<Text>{row.original.user.name}</Text>
</Group>
);
const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance<ApiKey> }) => {
const meta = table.options.meta as ApiKeysTableMeta;
return (
<Group gap="xs">
<InlineConfirmActionIcon
onConfirm={() => meta.handleDelete(row.original.id)}
confirmLabel={meta.confirmLabel}
confirmationAriaLabel={meta.confirmLabel}
loading={meta.isPendingDelete}
color="red"
variant="subtle"
aria-label={meta.deleteTitle}
>
<IconTrash size="1rem" />
</InlineConfirmActionIcon>
</Group>
);
};
export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => {
const [newApiKey, setNewApiKey] = useState<string | null>(null);
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { openConfirmModal } = useConfirmModal();
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
onSuccess(data) {
setNewApiKey(data.apiKey);
onCreated();
async onSuccess(data) {
openModal({
apiKey: data.apiKey,
});
await revalidatePathActionAsync("/manage/tools/api");
},
});
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
@@ -71,19 +38,21 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
});
const t = useI18n("management.page.tool.api.tab.apiKey");
const tCommon = useI18n("common");
const handleDelete = useCallback(
async (id: string) => {
await mutateDeleteAsync({ apiKeyId: id });
(id: string) => {
openConfirmModal({
title: t("modal.delete.title"),
children: t("modal.delete.text"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateDeleteAsync({ apiKeyId: id });
},
});
},
[mutateDeleteAsync],
[t, openConfirmModal, mutateDeleteAsync],
);
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
() => [
{
accessorKey: "id",
@@ -92,32 +61,36 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ApiKeyCreatedByCell,
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
},
{
header: t("table.header.actions"),
Cell: ApiKeyActionsCell,
Cell: ({ row }) => (
<Group gap="xs">
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
<IconTrash size="1rem" />
</ActionIcon>
</Group>
),
},
],
[t],
[t, handleDelete, isPendingDelete],
);
const table = useMantineReactTable({
columns,
data: apiKeys,
meta: {
handleDelete,
confirmLabel: tCommon("action.confirm"),
deleteTitle: t("modal.delete.title"),
isPendingDelete,
},
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutateCreate();
}}
loading={isPendingCreate}
disabled={newApiKey !== null}
>
{t("button.createApiToken")}
</Button>
@@ -131,7 +104,6 @@ export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps
return (
<Stack>
<Title>{t("title")}</Title>
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<MantineReactTable table={table} />
</Stack>
);
@@ -5,8 +5,6 @@ import type { ReactNode } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { ApiKeysManagement } from "./api-keys";
import { McpInstructions } from "./mcp-instructions";
export interface McpToolGroup {
@@ -19,8 +17,9 @@ interface ApiPageTabsProps {
apiKeyLabel: string;
mcpLabel: string;
documentationPanel: ReactNode;
apiKeys: RouterOutputs["apiKeys"]["getAll"];
authenticationPanel: ReactNode;
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
@@ -29,12 +28,12 @@ export function ApiPageTabs({
apiKeyLabel,
mcpLabel,
documentationPanel,
apiKeys,
authenticationPanel,
baseUrl,
hasApiKeys,
toolGroups,
}: ApiPageTabsProps) {
const [activeTab, setActiveTab] = useState<string | null>("documentation");
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
@@ -49,17 +48,10 @@ export function ApiPageTabs({
{mcpLabel}
</TabsTab>
</TabsList>
<TabsPanel value="authentication">
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
</TabsPanel>
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
<TabsPanel value="mcp">
<McpInstructions
baseUrl={baseUrl}
hasApiKeys={hasApiKeys}
toolGroups={toolGroups}
onApiKeyCreated={() => setHasApiKeys(true)}
/>
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
</TabsPanel>
</Tabs>
);
@@ -0,0 +1,34 @@
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const [visible, { toggle }] = useDisclosure(false);
return (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});
@@ -1,6 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import {
Accordion,
AccordionControl,
@@ -37,11 +36,12 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { CopyApiKeyModal } from "./copy-api-key-modal";
import type { McpToolGroup } from "./api-page-tabs";
import classes from "./mcp-tools-accordion.module.css";
import { NewApiKeyAlert } from "./new-api-key-alert";
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
query: { color: "blue", method: "GET" },
@@ -52,23 +52,18 @@ interface McpInstructionsProps {
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
onApiKeyCreated: () => void;
}
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) {
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
const t = useI18n("management.page.tool.api.tab.mcp");
const tCommon = useI18n("common");
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const { openModal } = useModalAction(CopyApiKeyModal);
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
onSuccess(data) {
setNewApiKey(data.apiKey);
onApiKeyCreated();
async onSuccess(data) {
openModal({ apiKey: data.apiKey });
await revalidatePathActionAsync("/manage/tools/api");
},
});
const handleDismissNewApiKey = useCallback(async () => {
setNewApiKey(null);
await revalidatePathActionAsync("/manage/tools/api");
}, []);
const mcpUrl = `${baseUrl}/api/mcp`;
const streamableHttpConfig = JSON.stringify(
@@ -119,7 +114,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
<Text size="sm">{t("description")}</Text>
</Alert>
{!hasApiKeys && !newApiKey && (
{!hasApiKeys && (
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
<Group justify="space-between" align="center">
<Text size="sm">{t("noApiKey")}</Text>
@@ -136,8 +131,6 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
</Alert>
)}
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
<div>
<Title order={5} mb="xs">
<Group gap="xs">
@@ -312,7 +305,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreat
<Stack gap={6}>
{group.tools.map((tool) => (
<div key={tool.name} className={classes.toolRow}>
<Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light">
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
{toolTypeDisplay[tool.type].method}
</Badge>
<div>
@@ -1,50 +0,0 @@
"use client";
import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface NewApiKeyAlertProps {
apiKey: string;
onDismiss: () => void | Promise<void>;
}
export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => {
const t = useI18n("management.page.tool.api.modal.createApiToken");
const tCommon = useI18n("common");
const [visible, { toggle }] = useDisclosure(false);
return (
<Alert color="yellow" title={t("title")}>
<Stack gap="sm">
<Text size="sm">{t("description")}</Text>
<PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly />
<Stack gap="xs" align="stretch">
<CopyButton value={apiKey} timeout={2_000}>
{({ copied, copy }) => {
let color: "green" | undefined;
let icon = <IconCopy size={16} />;
let label = tCommon("action.copy");
if (copied) {
color = "green";
icon = <IconCheck size={16} />;
label = tCommon("action.copied");
}
return (
<Button onClick={copy} variant="default" color={color} leftSection={icon}>
{label}
</Button>
);
}}
</CopyButton>
<Button onClick={onDismiss} variant="subtle">
{tCommon("action.close")}
</Button>
</Stack>
</Stack>
</Alert>
);
};
@@ -11,6 +11,7 @@ import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
@@ -86,8 +87,9 @@ export default async function ApiPage() {
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
apiKeys={apiKeys}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
@@ -1,35 +1,23 @@
"use client";
import { Button, Card, Collapse, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Button } from "@mantine/core";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { CertificateUploadForm } from "./certificate-upload-form";
export const AddCertificateSection = () => {
const [opened, { close, toggle }] = useDisclosure(false);
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
const t = useI18n("certificate");
return (
<Stack gap="sm">
<Button onClick={toggle} variant="default" aria-expanded={opened}>
{t("action.create.label")}
</Button>
<Collapse expanded={opened}>
<Card withBorder>
<CertificateUploadForm
onCancel={close}
onSuccess={async () => {
await revalidatePathActionAsync("/manage/tools/certificates");
close();
}}
/>
</Card>
</Collapse>
</Stack>
);
};
const handleClick = () => {
openModal({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
};
export { CertificateUploadForm };
return <Button onClick={handleClick}>{t("action.create.label")}</Button>;
};
@@ -1,110 +0,0 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { checkCertificateFile } from "@homarr/validation/certificates";
interface CertificateUploadFormProps {
embedded?: boolean;
onSuccess?: () => MaybePromise<void>;
onCancel?: () => void;
}
export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.file().check(checkCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation();
const handleSubmit = async () => {
const validation = form.validate();
if (validation.hasErrors || !form.values.file) return;
const formData = new FormData();
formData.set("file", form.values.file);
await mutateAsync(formData, {
onSuccess: async () => {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
form.reset();
await onSuccess?.();
},
onError: () => {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
};
const handleCancel = () => {
form.reset();
onCancel?.();
};
const submit = () => {
void handleSubmit().catch(() => undefined);
};
let submitButtonType: "button" | "submit" = "submit";
let handleSubmitClick: (() => void) | undefined;
if (embedded) {
submitButtonType = "button";
handleSubmitClick = submit;
}
const content = (
<>
<FileInput
label={t("certificate.action.create.label")}
leftSection={<IconCertificate size={16} />}
{...form.getInputProps("file")}
/>
<Group justify="end">
{onCancel && (
<Button onClick={handleCancel} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
)}
<Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}>
{t("common.action.add")}
</Button>
</Group>
</>
);
if (embedded) {
return <Stack>{content}</Stack>;
}
return (
<Stack
component="form"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{content}
</Stack>
);
};
@@ -1,56 +1,57 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleRemove = () =>
mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
const handleClick = () => {
openConfirmModal({
title: t("action.remove.label"),
children: t("action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
onSuccess() {
showSuccessNotification({
title: t("action.remove.notification.success.title"),
message: t("action.remove.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.remove.notification.error.title"),
message: t("action.remove.notification.error.message"),
});
},
},
);
},
);
});
};
return (
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.remove.label")}
>
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<IconTrash color="red" size={16} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -1,12 +1,13 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
interface RemoveHostnameActionIconProps {
hostname: string;
@@ -14,41 +15,41 @@ interface RemoveHostnameActionIconProps {
}
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
},
});
const { openConfirmModal } = useConfirmModal();
const t = useI18n("certificate");
const tCommon = useI18n("common");
const handleRemove = () =>
mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
const handleRemove = () => {
openConfirmModal({
title: t("action.removeHostname.label"),
children: t("action.removeHostname.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(input, {
onSuccess() {
showSuccessNotification({
title: t("action.removeHostname.notification.success.title"),
message: t("action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("action.removeHostname.notification.error.title"),
message: t("action.removeHostname.notification.error.message"),
});
},
});
},
});
};
return (
<InlineConfirmActionIcon
onConfirm={handleRemove}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
color="red"
variant="subtle"
aria-label={t("action.removeHostname.label")}
>
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
<IconTrash color="red" size={16} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -13,7 +13,7 @@ import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateSection } from "./_components/add-certificate";
import { AddCertificateButton } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
@@ -69,11 +69,10 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
{t("page.list.toHostnames")}
</Button>
<AddCertificateButton />
</Group>
</Group>
<AddCertificateSection />
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} />
)}
@@ -2,18 +2,20 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const tUser = useI18n("user");
const tCommon = useI18n("common");
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
@@ -21,14 +23,27 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(
() => mutateUserDeletionAsync({ userId: user.id }),
[mutateUserDeletionAsync, user.id],
() =>
openConfirmModal({
title: tUser("action.delete.label"),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
children: tUser("action.delete.confirm", { username: user.name! }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync({
userId: user.id,
});
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, tUser],
);
return (
<InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red">
<Button onClick={handleDelete} variant="subtle" color="red">
{tCommon("action.delete")}
</InlineConfirmButton>
</Button>
);
};
@@ -8,9 +8,10 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
interface UserProfileAvatarForm {
@@ -18,7 +19,7 @@ interface UserProfileAvatarForm {
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({
const { mutate } = clientApi.user.setProfileImage.useMutation({
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
@@ -26,6 +27,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
});
const [opened, { toggle }] = useDisclosure(false);
const isMobile = useIsMobile();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -66,28 +68,32 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
[mutate, user.id, tManageAvatar],
);
const handleRemoveAvatar = useCallback(
() =>
mutateAsync(
{
userId: user.id,
image: null,
},
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
const handleRemoveAvatar = useCallback(() => {
openConfirmModal({
title: tManageAvatar("removeImage.label"),
children: tManageAvatar("removeImage.confirm"),
onConfirm() {
mutate(
{
userId: user.id,
image: null,
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
{
onSuccess() {
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
},
},
),
[mutateAsync, user.id, tManageAvatar],
);
);
},
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
return (
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
@@ -118,15 +124,9 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
)}
</FileButton>
{user.image && (
<InlineConfirmMenuItem
color="red"
onConfirm={handleRemoveAvatar}
confirmLabel={tCommon("action.confirm")}
disabled={isPending}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
{tManageAvatar("removeImage.label")}
</InlineConfirmMenuItem>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
@@ -12,6 +12,7 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
@@ -19,7 +20,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelect } from "~/components/access/group-select";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { StepperNavigationComponent } from "./stepper-navigation";
interface GroupWithPermissions {
@@ -223,6 +224,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
const tCommon = useI18n("common");
const tPermission = useI18n("permission");
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
@@ -238,23 +256,14 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) =
{tCreate("step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<GroupSelect
presentGroupIds={groups.map((group) => group.id)}
withPermissions
onSelect={({ id, name, permissions }) => {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
}}
triggerProps={{
variant: "subtle",
color: "gray",
leftSection: <IconPlus size={16} stroke={1.5} />,
}}
/>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{tCommon("action.add")}
</Button>
</Group>
<Table>
<Table.Thead>
@@ -2,13 +2,13 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { useMatches } from "@mantine/core";
import { Button, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface DeleteGroupProps {
group: {
@@ -20,38 +20,46 @@ interface DeleteGroupProps {
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
const router = useRouter();
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
const { openConfirmModal } = useConfirmModal();
const tDelete = useI18n("group.action.delete");
const tCommon = useI18n("common");
const handleDeletion = useCallback(
() =>
mutateAsync(
{
id: group.id,
},
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tDelete("label"),
children: tDelete("confirm", {
name: group.name,
}),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{
id: group.id,
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
{
onSuccess() {
void revalidatePathActionAsync("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
},
},
),
[tDelete, tCommon, group.id, group.name, mutateAsync, router],
);
);
},
});
}, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]);
const fullWidth = useMatches({
xs: true,
@@ -60,14 +68,8 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
});
return (
<InlineConfirmButton
variant="subtle"
color="red"
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
fullWidth={fullWidth}
>
<Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}>
{tDelete("label")}
</InlineConfirmButton>
</Button>
);
};
@@ -1,14 +1,15 @@
"use client";
import { useState } from "react";
import { Button, Group, Stack, Text, useMatches } from "@mantine/core";
import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserSelect } from "~/components/access/user-select";
import { UserSelectModal } from "~/components/access/user-select-modal";
interface TransferGroupOwnershipProps {
group: {
@@ -18,87 +19,75 @@ interface TransferGroupOwnershipProps {
};
}
interface SelectedOwner {
id: string;
name: string;
}
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
const tTransfer = useI18n("group.action.transfer");
const tCommon = useI18n("common");
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>();
const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({
const { openModal } = useModalAction(UserSelectModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.group.transferOwnership.useMutation({
async onSuccess() {
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
},
});
const handleTransfer = async () => {
if (!selectedOwner) return;
await mutateAsync(
const handleTransfer = useCallback(() => {
openModal(
{
groupId: group.id,
userId: selectedOwner.id,
confirmLabel: tCommon("action.continue"),
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
onSelect: ({ id, name }) => {
openConfirmModal({
title: tTransfer("label"),
children: tTransfer("confirm", {
name: group.name,
username: name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync(
{
groupId: group.id,
userId: id,
},
{
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: name,
}),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
);
},
});
},
},
{
onSuccess() {
setInnerOwnerId(selectedOwner.id);
showSuccessNotification({
title: tCommon("notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: selectedOwner.name,
}),
});
setSelectedOwner(undefined);
},
onError() {
showErrorNotification({
title: tCommon("notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
title: tTransfer("label"),
},
);
};
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]);
const fullWidth = useMatches({
xs: true,
sm: true,
md: false,
});
const presentUserIds: string[] = [];
if (innerOwnerId) presentUserIds.push(innerOwnerId);
if (!selectedOwner) {
return (
<UserSelect
presentUserIds={presentUserIds}
onSelect={setSelectedOwner}
triggerLabel={tTransfer("label")}
triggerProps={{ variant: "subtle", color: "red", fullWidth }}
/>
);
}
return (
<Stack gap="xs" maw={420}>
<Text size="sm">
{tTransfer("confirm", {
name: group.name,
username: selectedOwner.name,
})}
</Text>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}>
{tCommon("action.cancel")}
</Button>
<Button color="red" onClick={() => void handleTransfer()} loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
<Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}>
{tTransfer("label")}
</Button>
);
};
@@ -5,9 +5,11 @@ import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserSelect } from "~/components/access/user-select";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface AddGroupMemberProps {
groupId: string;
@@ -18,23 +20,27 @@ interface AddGroupMemberProps {
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
const tMembersAdd = useI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const handleAddMember = useCallback(
async ({ id }: { id: string }) => {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
[groupId, mutateAsync],
);
const { openModal } = useModalAction(UserSelectModal);
return (
<UserSelect
presentUserIds={presentUserIds}
allowedProviders={allowedProviders}
onSelect={handleAddMember}
triggerLabel={tMembersAdd("label")}
/>
);
const handleAddMember = useCallback(() => {
openModal(
{
// eslint-disable-next-line no-restricted-syntax
async onSelect({ id }) {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
allowedProviders,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
};
@@ -1,11 +1,12 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
interface RemoveGroupMemberProps {
groupId: string;
@@ -14,25 +15,30 @@ interface RemoveGroupMemberProps {
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
const tCommon = useI18n("common");
const tRemoveMember = useI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
const { openConfirmModal } = useConfirmModal();
const handleRemove = useCallback(async () => {
await mutateAsync({
groupId,
userId: user.id,
const handleRemove = useCallback(() => {
openConfirmModal({
title: tRemoveMember("label"),
children: tRemoveMember("confirm", {
user: user.name ?? "",
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({
groupId,
userId: user.id,
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
});
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
}, [mutateAsync, groupId, user.id]);
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
return (
<InlineConfirmButton
variant="subtle"
color="red.9"
size="compact-sm"
confirmLabel={tCommon("action.confirm")}
onConfirm={handleRemove}
>
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
{tCommon("action.remove")}
</InlineConfirmButton>
</Button>
);
};
@@ -1,14 +1,15 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core";
import { useMemo, useState } from "react";
import { TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { GroupCreateForm } from "./_components/group-create-form";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { GroupsTable } from "./_groups-table";
interface GroupsListProps {
@@ -17,10 +18,6 @@ interface GroupsListProps {
export const GroupsList = ({ groups }: GroupsListProps) => {
const [search, setSearch] = useState("");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const initialGroupIds = useMemo(
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
[groups],
@@ -34,46 +31,8 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
);
const tGroup = useI18n("group");
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<Stack>
<Group justify="end">
<Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button>
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<GroupCreateForm
key={formKey}
onCancel={closeCreate}
onCreated={() => {
closeCreate();
router.refresh();
}}
/>
</Card>
</Collapse>
<>
<TextInput
leftSection={<IconSearch size={20} stroke={1.5} />}
value={search}
@@ -81,6 +40,13 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
placeholder={`${tGroup("search")}...`}
/>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</Stack>
</>
);
};
export const AddGroupButton = () => {
const tGroup = useI18n("group");
const { openModal } = useModalAction(AddGroupModal);
return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>;
};
@@ -1,61 +0,0 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
interface GroupCreateFormProps {
onCancel: () => void;
onCreated: () => void;
}
export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => {
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation();
const handleSubmit = form.onSubmit(async (values) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
onCreated();
},
onError() {
showErrorNotification({
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} />
<Group justify="end">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { GroupsList } from "./_client";
import { AddGroupButton, GroupsList } from "./_client";
import classes from "./groups.module.css";
export default async function GroupsListPage() {
@@ -26,7 +26,7 @@ export default async function GroupsListPage() {
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return (
<ManagePageLayout title={tCommon("entity.groups")}>
<ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
<Stack>
{dbEveryoneGroup && (
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
@@ -2,15 +2,18 @@
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => {
export const InviteCreateButton = () => {
const t = useI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
return (
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}>
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
{t("action.new.title")}
</Button>
);
@@ -1,138 +0,0 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
type FormType = {
expirationDate: string;
};
type CreatedInvite = RouterOutputs["invite"]["createInvite"];
interface InviteCreateFormProps {
onClose: () => void;
}
export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const [createdInvite, setCreatedInvite] = useState<CreatedInvite>();
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
const minDate = dayjs().add(1, "hour").toDate();
const maxDate = dayjs().add(6, "months").toDate();
const form = useForm<FormType>({
initialValues: {
expirationDate: dayjs().add(4, "hours").toDate().toISOString(),
},
});
if (createdInvite) {
return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />;
}
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
void utils.invite.getAll.invalidate();
setCreatedInvite(result);
},
},
);
})}
>
<Stack>
<Text>{tInvite("action.new.description")}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY HH:mm"
label={tInvite("field.expirationDate.label")}
variant="filled"
{...form.getInputProps("expirationDate")}
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => {
const tInvite = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const inviteUrl = createInviteUrl(invite);
return (
<Stack component="output" aria-live="polite">
<Text>
{tInvite.rich("action.copy.description", {
b: bold,
})}
</Text>
<Stack gap="xs">
<Text fw="bold">{tInvite("action.copy.link")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
<Anchor href={inviteUrl} target="_blank" rel="noreferrer">
{inviteUrl}
</Anchor>
</Mark>
<Text fw="bold">{invariantTechnicalLabels.id}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.id}
</Mark>
<Text fw="bold">{tInvite("field.token.label")}:</Text>
<Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}>
{invite.token}
</Mark>
</Stack>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onClose}>
{tCommon("action.close")}
</Button>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
onClose();
}}
>
{tInvite("action.copy.button")}
</Button>
)}
</CopyButton>
</Group>
</Stack>
);
};
const bold = (children: ReactNode) => <b>{children}</b>;
const createInviteUrl = ({ id, token }: CreatedInvite) => {
const path = `/auth/invite/${id}?token=${token}`;
return new URL(path, window.location.origin).toString();
};
@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, Collapse, Group, Stack } from "@mantine/core";
import { useCallback, useMemo } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -12,11 +11,9 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { InviteCreateButton } from "./invite-create-button";
import { InviteCreateForm } from "./invite-create-form";
dayjs.extend(relativeTime);
@@ -26,10 +23,6 @@ interface InviteListComponentProps {
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
const t = useI18n("management.page.user.invite");
const router = useRouter();
const searchParams = useSearchParams();
const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites,
refetchOnMount: false,
@@ -80,65 +73,28 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
},
});
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsCreateOpen(true);
}, [searchParams]);
const closeCreate = () => {
setIsCreateOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false });
};
const toggleCreate = () => {
if (isCreateOpen) {
closeCreate();
return;
}
setFormKey((value) => value + 1);
setIsCreateOpen(true);
};
return (
<Stack>
<Group justify="end">
<InviteCreateButton onClick={toggleCreate} />
</Group>
<Collapse expanded={isCreateOpen}>
<Card withBorder>
<InviteCreateForm key={formKey} onClose={closeCreate} />
</Card>
</Collapse>
<MantineReactTable table={table} />
</Stack>
);
return <MantineReactTable table={table} />;
};
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
const t = useI18n("management.page.user.invite");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(() => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
}, [row.original.id, mutate, utils]);
openConfirmModal({
title: t("action.delete.title"),
children: t("action.delete.description"),
onConfirm: () => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
},
});
}, [openConfirmModal, row.original.id, mutate, utils, t]);
return (
<InlineConfirmActionIcon
variant="subtle"
color="red"
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
loading={isPending}
aria-label={t("action.delete.title")}
>
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
<IconTrash color="red" size={20} stroke={1.5} />
</InlineConfirmActionIcon>
</ActionIcon>
);
};
@@ -5,7 +5,9 @@ import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getI18n } from "@homarr/translation/server";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { InviteCreateButton } from "./_components/invite-create-button";
import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() {
@@ -22,7 +24,15 @@ export default async function InvitesOverviewPage() {
const initialInvites = await api.invite.getAll();
return (
<ManagePageLayout title={t("title")}>
<ManagePageLayout
title={t("title")}
primaryAction={
<ManageMobilePrimaryAction>
<InviteCreateButton />
</ManageMobilePrimaryAction>
}
floatingPrimaryAction
>
<InviteListComponent initialInvites={initialInvites} />
</ManagePageLayout>
);
@@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-popover";
import { PreviewDimensionsPopover } from "./_dimension-popover";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
@@ -32,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const t = useI18n();
const utils = clientApi.useUtils();
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
@@ -97,6 +98,13 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
});
}, [editMode, t]);
const openDimensionsModal = useCallback(() => {
openPreviewDimensionsModal({
dimensions,
setDimensions,
});
}, [dimensions, openPreviewDimensionsModal]);
const updateOptions = useCallback(
({ newOptions }: { newOptions: Record<string, unknown> }) =>
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
@@ -154,21 +162,15 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
</ActionIcon>
</Affix>
<Affix bottom={12} right={72 + 120}>
<PreviewDimensionsPopover
dimensions={dimensions}
setDimensions={setDimensions}
target={(onClick) => (
<ActionIcon
size={48}
variant="default"
radius="xl"
aria-label={t("widgetPreview.dimensions.title")}
onClick={onClick}
>
<IconDimensions size={24} />
</ActionIcon>
)}
/>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
aria-label={t("widgetPreview.dimensions.title")}
>
<IconDimensions size={24} />
</ActionIcon>
</Affix>
</>
);
@@ -0,0 +1,50 @@
"use client";
import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.dimensions,
});
const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values);
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("widgetPreview.dimensions.title"),
});
export interface Dimensions {
width: number;
height: number;
}
@@ -1,78 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
interface PreviewDimensionsPopoverProps {
target: (onClick: () => void) => ReactNode;
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => {
const t = useI18n();
const [opened, { close, open }] = useDisclosure(false);
const form = useForm({
initialValues: dimensions,
});
const handleSubmit = (values: Dimensions) => {
setDimensions(values);
close();
};
const handleClose = () => {
form.setValues(dimensions);
close();
};
const handleToggle = () => {
if (opened) {
handleClose();
return;
}
open();
};
return (
<Popover
opened={opened}
onChange={(nextOpened) => {
if (nextOpened) open();
if (!nextOpened) handleClose();
}}
position="top-end"
width="min(320px, calc(100vw - 2rem))"
withArrow
>
<Popover.Target>{target(handleToggle)}</Popover.Target>
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.moveResize.field.width.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
</InputWrapper>
<InputWrapper label={t("item.moveResize.field.height.label")}>
<Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={handleClose}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
</Popover.Dropdown>
</Popover>
);
};
export interface Dimensions {
width: number;
height: number;
}
@@ -1,130 +0,0 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
import type { ButtonProps } from "@mantine/core";
export interface AccessPickerOption<T> {
value: string;
label: string;
keywords?: readonly string[];
content: ReactNode;
item: T;
}
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
interface AccessPickerProps<T> {
label: string;
searchPlaceholder: string;
emptyMessage: string;
triggerLabel: string;
options: AccessPickerOption<T>[];
isPending: boolean;
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
triggerProps?: AccessPickerTriggerProps;
}
export const AccessPicker = <T,>({
label,
searchPlaceholder,
emptyMessage,
triggerLabel,
options,
isPending,
onSelect,
triggerProps,
}: AccessPickerProps<T>) => {
const [search, setSearch] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
requestAnimationFrame(() => combobox.focusTarget());
},
onDropdownOpen: () => {
combobox.selectFirstOption();
requestAnimationFrame(() => combobox.focusSearchInput());
},
});
const normalizedSearch = search.trim().toLowerCase();
const filteredOptions = options.filter((option) => {
if (!normalizedSearch) return true;
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
return searchableText.includes(normalizedSearch);
});
const visibleOptions = filteredOptions.slice(0, 5);
const handleSelect = (value: string) => {
const option = options.find((item) => item.value === value);
if (!option || isSubmitting) return;
combobox.closeDropdown();
setIsSubmitting(true);
const submit = async () => {
try {
await onSelect(option);
} finally {
setIsSubmitting(false);
}
};
void submit().catch(() => undefined);
};
return (
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
<Combobox.Target targetType="button">
<Button
{...triggerProps}
onClick={() => combobox.toggleDropdown()}
rightSection={
<Group gap={4} wrap="nowrap">
{triggerProps?.rightSection}
<Combobox.Chevron />
</Group>
}
loading={isSubmitting}
disabled={isSubmitting || triggerProps?.disabled}
>
{triggerLabel}
</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
placeholder={searchPlaceholder}
aria-label={label}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Combobox.Options>
{isPending && (
<Center p="md">
<LoaderPlaceholder />
</Center>
)}
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
{!isPending &&
visibleOptions.map((option) => (
<Combobox.Option value={option.value} key={option.value}>
{option.content}
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
const LoaderPlaceholder = () => <Loader size="sm" />;
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -10,7 +11,7 @@ import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelect } from "./group-select";
import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
@@ -23,6 +24,8 @@ export const GroupAccessForm = <TPermission extends string>({
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -33,6 +36,23 @@ export const GroupAccessForm = <TPermission extends string>({
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
@@ -59,21 +79,9 @@ export const GroupAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<GroupSelect
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
onSelect={(group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveGroup")}
</Button>
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={tGroup("label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tGroup("notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});
@@ -1,66 +0,0 @@
"use client";
import { Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
interface GroupSelectProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const GroupSelect = ({
withPermissions,
presentGroupIds,
onSelect,
triggerLabel,
triggerProps,
}: GroupSelectProps) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
value: group.id,
label: group.name,
keywords: getGroupPermissions(group) ?? [],
item: group,
content: <Text size="sm">{group.name}</Text>,
}));
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
onSelect({
id: group.id,
name: group.name,
permissions: getGroupPermissions(group),
});
return (
<AccessPicker
label={tGroup("label")}
searchPlaceholder={tGroup("label")}
emptyMessage={tGroup("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
const getGroupPermissions = (group: SelectableGroup) => {
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
return undefined;
};
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -10,7 +11,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelect } from "./user-select";
import { UserSelectModal } from "./user-select-modal";
export interface FormProps<TPermission extends string> {
entity: {
@@ -40,6 +41,8 @@ export const UsersAccessForm = <TPermission extends string>({
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -49,8 +52,25 @@ export const UsersAccessForm = <TPermission extends string>({
})),
},
});
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
if (entity.ownerId) presentUserIds.push(entity.ownerId);
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
@@ -81,21 +101,9 @@ export const UsersAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<UserSelect
presentUserIds={presentUserIds}
onSelect={(user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveUser")}
</Button>
@@ -0,0 +1,103 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
email: currentUser.email ?? null,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={tUser("label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={tUser("notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
@@ -1,75 +0,0 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableUser = RouterOutputs["user"]["selectable"][number];
interface UserSelectProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const UserSelect = ({
presentUserIds,
allowedProviders,
onSelect,
triggerLabel,
triggerProps,
}: UserSelectProps) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
value: user.id,
label: user.name ?? "",
keywords: [user.email ?? ""],
item: user,
content: (
<Group wrap="nowrap" gap="xs">
<UserAvatar user={user} size="xs" />
<Stack gap={0}>
<Text size="sm">{user.name}</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</Stack>
</Group>
),
}));
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
onSelect({
id: user.id,
name: user.name ?? "",
image: user.image ?? "",
email: user.email ?? null,
});
return (
<AccessPicker
label={tUser("label")}
searchPlaceholder={tUser("label")}
emptyMessage={tUser("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
@@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -2001,6 +2001,7 @@ const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
@@ -2053,15 +2054,23 @@ const ThreadListItem = () => {
}
};
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
};
if (renameOpened) {
@@ -2143,19 +2152,16 @@ const ThreadListItem = () => {
>
<IconFileExport size={14} />
</ActionIcon>
<InlineConfirmActionIcon
<ActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
onClick={deleteConversation}
>
<IconTrash size={14} />
</InlineConfirmActionIcon>
</ActionIcon>
</Group>
)}
<ActionIcon
@@ -334,7 +334,6 @@ const LoadedBoardItemContent = ({
item={item}
width={width}
height={height}
displayScale={boardCanvasScale}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
@@ -437,7 +436,6 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
displayScale: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
displayMode: "compact" | "advanced";
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -8,14 +8,14 @@ import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
@@ -142,14 +142,11 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
onConfirm={() => removeItem({ itemId: item.id })}
>
{tItem("action.remove")}
</InlineConfirmMenuItem>
/>
</Menu.Dropdown>
</Menu>
);
@@ -30,15 +30,11 @@
--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));
@@ -0,0 +1,70 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { boardRenameSchema } from "@homarr/validation/board";
interface InnerProps {
id: string;
previousName: string;
onSuccess?: (name: string) => void;
}
export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => {
const utils = clientApi.useUtils();
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
},
});
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: innerProps.previousName,
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
id: innerProps.id,
name: values.name,
},
{
onSuccess: () => {
actions.closeModal();
innerProps.onSuccess?.(values.name);
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus />
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"),
});
type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">;
@@ -1,12 +1,12 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
@@ -59,14 +59,11 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<InlineConfirmMenuItem
color="red"
confirmLabel={tContainer("remove.message")}
leftSection={<IconTrash size={16} />}
<BoardRemoveConfirmationMenuItem
label={tContainer("action.remove")}
confirmationLabel={tContainer("remove.message")}
onConfirm={() => removeContainer({ id: section.id })}
>
{tContainer("action.remove")}
</InlineConfirmMenuItem>
/>
</Menu.Dropdown>
</Menu>
);
@@ -1,99 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportFormProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setCategory("other");
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Collapse id="workshop-report-form" expanded={opened}>
<Paper p="md">
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<Stack>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fw={600}>{t("reportTitle")}</Text>
<Text size="sm" c="dimmed">
{t("reportExplanation")}
</Text>
</Box>
<CloseButton aria-label={tCommon("action.close")} onClick={close} />
</Group>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Collapse>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportModalProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
<Stack>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{tCommon("action.cancel")}
</Button>
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</Modal>
);
}
-1
View File
@@ -43,7 +43,6 @@
"lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
-2
View File
@@ -1,8 +1,6 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./board-create/board-create-form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";
@@ -1,5 +1,3 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
@@ -8,16 +6,12 @@ import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
interface BoardCreateFormProps {
onCancel?: () => void;
}
export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
export const AddBoardModal = createModal(({ actions }) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
@@ -44,6 +38,7 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
if (!boardNameStatus.canSubmit) return;
mutate(values, {
onSuccess: (result) => {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.create.notification.success.title"),
message: tBoard("action.create.notification.success.message", { name: values.name }),
@@ -61,17 +56,24 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
});
})}
>
<Stack maw={720}>
<Stack>
<TextInput
label={tCommon("field.name")}
withAsterisk
autoFocus
data-autofocus
{...form.getInputProps("name")}
description={<BoardNameAvailability status={boardNameStatus} />}
description={
boardNameStatus.description ? (
<span>
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
</span>
) : null
}
/>
<InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider
aria-label={tBoard("field.columnCount.label")}
min={boardColumnCountSchema.minValue ?? undefined}
max={boardColumnCountSchema.maxValue ?? undefined}
step={1}
@@ -85,71 +87,50 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
{...form.getInputProps("isPublic")}
/>
<Group justify="end" wrap="wrap">
{onCancel && (
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
)}
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
}).withOptions({
defaultTitle: (t) => t("management.page.board.action.new.label"),
});
export const useBoardNameStatus = (name: string) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const [debouncedName] = useDebouncedValue(name, 250);
const isValidName = boardNameSchema.safeParse(name).success;
const isDebouncing = name !== debouncedName;
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
enabled: boardNameSchema.safeParse(debouncedName).success,
});
let description: { label: string; icon?: TablerIcon; color?: string } | undefined;
if (debouncedName.trim() !== "") {
if (isDebouncing || isLoading) {
description = {
label: tBoard("action.create.availability.checking"),
};
} else if (boardExists !== undefined) {
if (boardExists) {
description = {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"),
color: "red",
};
} else {
description = {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
};
}
}
}
return {
canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading,
description,
canSubmit: !boardExists && !isLoading,
description:
debouncedName.trim() === ""
? undefined
: isLoading
? {
label: tBoard("action.create.availability.checking"),
}
: boardExists === undefined
? undefined
: boardExists
? {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
color: "red",
}
: {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
},
};
};
export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>;
export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => {
if (!status.description) return null;
const Icon = status.description.icon;
return (
<Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite">
{Icon && <Icon size={16} />}
<span>{status.description.label}</span>
</Group>
);
};
@@ -1,32 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardDuplicateSchema } from "@homarr/validation/board";
interface DuplicateBoardFormProps {
import { createModal } from "../../../modals/src/creator";
import { useBoardNameStatus } from "./add-board-modal";
interface InnerProps {
board: {
id: string;
name: string;
};
onCancel: () => void;
}
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
mode: "controlled",
initialValues: {
name: board.name,
name: innerProps.board.name,
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
@@ -39,21 +36,20 @@ export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps)
return (
<form
onSubmit={form.onSubmit(async (values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
await mutateAsync(
{
...values,
id: board.id,
id: innerProps.board.id,
},
{
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: tBoard("action.duplicate.notification.success.title"),
message: tBoard("action.duplicate.notification.success.message"),
});
onCancel();
router.refresh();
},
onError() {
showErrorNotification({
@@ -65,26 +61,39 @@ export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps)
);
})}
>
<Stack maw={720}>
<Stack>
<Text size="sm" c="gray.6">
{tBoard("action.duplicate.message", { name: board.name })}
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
</Text>
<TextInput
label={tCommon("field.name")}
autoFocus
data-autofocus
{...form.getInputProps("name")}
description={<BoardNameAvailability status={boardNameStatus} />}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
withAsterisk
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onCancel}>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tBoard("action.duplicate.title")}
<Button type="submit" loading={isPending}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
}).withOptions({
defaultTitle(t) {
return t("board.action.duplicate.title");
},
});
@@ -0,0 +1,2 @@
export { AddBoardModal } from "./add-board-modal";
export { DuplicateBoardModal } from "./duplicate-board-modal";
@@ -0,0 +1,75 @@
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { checkCertificateFile } from "@homarr/validation/certificates";
interface InnerProps {
onSuccess?: () => MaybePromise<void>;
}
export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.file().check(checkCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation({
async onSuccess() {
await innerProps.onSuccess?.();
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
const formData = new FormData();
formData.set("file", values.file);
await mutateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
actions.closeModal();
},
onError() {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={form.submitting}>
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("certificate.action.create.label");
},
});
@@ -0,0 +1 @@
export * from "./add-certificate-modal";
@@ -0,0 +1,57 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
export const AddGroupModal = createModal<void>(({ actions }) => {
const tGroup = useI18n("group");
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: tCommon("notification.create.success"),
message: tGroup("action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tCommon("notification.create.error"),
message: tGroup("action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={tCommon("field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button loading={isPending} type="submit">
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});
@@ -0,0 +1 @@
export { AddGroupModal } from "./add-group-modal";
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more