Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa667b8cc | ||
|
|
d3192688c6 | ||
|
|
11626eda8a | ||
|
|
f58c0ac77c | ||
|
|
b3e24f0d34 | ||
|
|
7d742c9efa | ||
|
|
1b843817a4 | ||
|
|
8ae9630b3e | ||
|
|
63f5c43b69 | ||
|
|
e97fe7861d | ||
|
|
19760e5722 | ||
|
|
2570de25a6 | ||
|
|
f46ccadbe4 | ||
|
|
6297ca4b16 | ||
|
|
2accdc2fad | ||
|
|
8a08293016 |
No files matched your search
@@ -132,8 +132,8 @@ Here you have the following options in the header:
|
|||||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||||
- **Board switcher**: Use this button to open another board that you can access.
|
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
|
||||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
it directly. The current board is left out of the results.
|
||||||
|
|
||||||
When entering the edit mode, you'll get some additional buttons to work with:
|
When entering the edit mode, you'll get some additional buttons to work with:
|
||||||
|
|
||||||
@@ -194,11 +194,15 @@ To configure this, click on the small 3 dots at the top of the item in the edit
|
|||||||
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
|
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
In the modal you can edit the app that you want to display:
|
The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
<img
|
||||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
src={editItemCurrent}
|
||||||
|
alt="App tile highlighted beside the Edit item side editor"
|
||||||
|
style={{ maxWidth: "620px" }}
|
||||||
|
/>
|
||||||
|
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||||
@@ -369,7 +373,7 @@ Each page highlights the list view and the create action so you know where to fi
|
|||||||
|
|
||||||
### Board tour
|
### Board tour
|
||||||
|
|
||||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
|
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
|
||||||
|
|
||||||
### Tour controls
|
### Tour controls
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 144 KiB |
@@ -27,6 +27,11 @@ opening it. Containers include their visible widgets and app icons instead of ap
|
|||||||
|
|
||||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||||
|
|
||||||
|
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
|
||||||
|
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
|
||||||
|
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
|
||||||
|
arrow keys to move through the results and press `Enter` to open the selected board.
|
||||||
|
|
||||||
## Create a new board
|
## Create a new board
|
||||||
|
|
||||||
When creating a board, the availability of the board name will be checked for you.
|
When creating a board, the availability of the board name will be checked for you.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Bookmarks"
|
title: "Bookmarks"
|
||||||
description: "Displays multiple app links"
|
description: "Keeps useful links together"
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
|
|||||||
|
|
||||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||||
|
|
||||||
The bookmarks widget allows you to display multiple links in a single widget.
|
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||||
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
|
||||||
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
|
||||||
|
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
|||||||
export const bookmarksWidget: WidgetDefinition = {
|
export const bookmarksWidget: WidgetDefinition = {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "Displays multiple app links",
|
description: "Keeps useful links together",
|
||||||
path: "../../widgets/bookmarks",
|
path: "../../widgets/bookmarks",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
description: "Layout in which the bookmarks are displayed.",
|
description: "How bookmarks use the available widget space.",
|
||||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||||
defaultValue: "Vertical",
|
defaultValue: "Adaptive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Appearance",
|
||||||
|
description: "Card surface style.",
|
||||||
|
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
|
||||||
|
defaultValue: "Soft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Card spacing",
|
||||||
|
description: "Space between bookmark cards.",
|
||||||
|
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
|
||||||
|
defaultValue: "Extra small",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hide title",
|
name: "Hide title",
|
||||||
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "List of bookmarks to display in the widget.",
|
description: "URLs and existing Apps shown by the widget.",
|
||||||
values: "Select one or more Apps directly, then order them as you like.",
|
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||||
defaultValue: "No bookmarks selected",
|
defaultValue: "No bookmarks selected",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Custom links",
|
|
||||||
description: "URLs to display without first creating an App.",
|
|
||||||
values: "List of URLs.",
|
|
||||||
defaultValue: "No custom links",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">Abstract homelab network</title>
|
||||||
|
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="base" cx="50%" cy="40%" r="80%">
|
||||||
|
<stop offset="0" stop-color="#172047"/>
|
||||||
|
<stop offset="0.48" stop-color="#0b1028"/>
|
||||||
|
<stop offset="1" stop-color="#060814"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="indigoGlow">
|
||||||
|
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
|
||||||
|
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="cyanGlow">
|
||||||
|
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
|
||||||
|
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop stop-color="#748ffc" stop-opacity="0.05"/>
|
||||||
|
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
|
||||||
|
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="blur">
|
||||||
|
<feGaussianBlur stdDeviation="55"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1920" height="1080" fill="url(#base)"/>
|
||||||
|
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
|
||||||
|
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
|
||||||
|
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
|
||||||
|
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
|
||||||
|
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
|
||||||
|
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#9aa8ff" opacity=".48">
|
||||||
|
<circle cx="248" cy="660" r="3"/>
|
||||||
|
<circle cx="718" cy="511" r="4"/>
|
||||||
|
<circle cx="1118" cy="287" r="3"/>
|
||||||
|
<circle cx="1450" cy="110" r="4"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#5ee0ee" opacity=".42">
|
||||||
|
<circle cx="435" cy="850" r="3"/>
|
||||||
|
<circle cx="908" cy="604" r="4"/>
|
||||||
|
<circle cx="1350" cy="510" r="3"/>
|
||||||
|
<circle cx="1715" cy="285" r="4"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
|
||||||
|
<circle cx="330" cy="90" r="260"/>
|
||||||
|
<circle cx="330" cy="90" r="345"/>
|
||||||
|
<circle cx="1680" cy="940" r="250"/>
|
||||||
|
<circle cx="1680" cy="940" r="335"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -19,35 +19,11 @@
|
|||||||
align-self: start;
|
align-self: start;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--board-gutter-viewport-height);
|
height: var(--board-gutter-viewport-height);
|
||||||
padding-top: 24px;
|
|
||||||
margin-top: -24px;
|
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gutter[data-board-editing="true"] {
|
.gutter[data-board-editing="true"] {
|
||||||
outline: 1px dashed var(--mantine-color-default-border);
|
outline: 0.0625rem solid var(--mantine-color-default-border);
|
||||||
outline-offset: -1px;
|
outline-offset: -0.0625rem;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gutter[data-board-editing="true"]::before {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
z-index: 2;
|
|
||||||
display: block;
|
|
||||||
width: fit-content;
|
|
||||||
height: 24px;
|
|
||||||
padding-inline: var(--mantine-spacing-xs);
|
|
||||||
margin-top: -24px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
line-height: 24px;
|
|
||||||
color: var(--mantine-color-dimmed);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: none;
|
|
||||||
content: attr(aria-label);
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { IconBulb } from "@tabler/icons-react";
|
import { IconBulb } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||||
|
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
||||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||||
@@ -31,6 +33,12 @@ import classes from "./_client.module.css";
|
|||||||
|
|
||||||
const APP_SHELL_INLINE_PADDING = 32;
|
const APP_SHELL_INLINE_PADDING = 32;
|
||||||
|
|
||||||
|
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||||
|
<GridEditorRegistryProvider>
|
||||||
|
<BoardSelectionProvider>{children}</BoardSelectionProvider>
|
||||||
|
</GridEditorRegistryProvider>
|
||||||
|
);
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const t = useI18n("board.landmark");
|
const t = useI18n("board.landmark");
|
||||||
@@ -68,7 +76,7 @@ export const ClientBoard = () => {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<BoardSelectionProvider>
|
<BoardSelectionGridProvider>
|
||||||
<BoardAdvancedFocusProvider>
|
<BoardAdvancedFocusProvider>
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
@@ -144,7 +152,7 @@ export const ClientBoard = () => {
|
|||||||
</FloatingTip>
|
</FloatingTip>
|
||||||
</Box>
|
</Box>
|
||||||
</BoardAdvancedFocusProvider>
|
</BoardAdvancedFocusProvider>
|
||||||
</BoardSelectionProvider>
|
</BoardSelectionGridProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (representativeWidth === null) return content;
|
if (representativeWidth === null) return content;
|
||||||
|
|||||||
@@ -3,18 +3,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
||||||
IconDeviceFloppy,
|
|
||||||
IconLayoutBoard,
|
|
||||||
IconPencil,
|
|
||||||
IconPencilOff,
|
|
||||||
IconReplace,
|
|
||||||
IconSettings,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
@@ -25,8 +16,6 @@ import { hotkeys } from "@homarr/definitions";
|
|||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
@@ -56,9 +45,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
const t = useI18n("board");
|
const t = useI18n("board");
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
|
||||||
if (!hasChangeAccess) {
|
if (!hasChangeAccess) return null;
|
||||||
return <SelectBoardsMenu />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -73,8 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</TourTarget>
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -235,47 +220,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectBoardsMenu = () => {
|
|
||||||
const t = useI18n("board");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
|
||||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TourTarget id="board-switcher">
|
|
||||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
|
||||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
|
||||||
<Menu.Target>
|
|
||||||
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
|
|
||||||
<IconReplace stroke={1.5} />
|
|
||||||
</HeaderButton>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
|
||||||
<ScrollArea.Autosize mah={300}>
|
|
||||||
{isPending && (
|
|
||||||
<Center p="xs">
|
|
||||||
<Loader size="xs" />
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
{boards.map((board) => (
|
|
||||||
<Menu.Item
|
|
||||||
key={board.id}
|
|
||||||
component={Link}
|
|
||||||
href={`/boards/${board.name}`}
|
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
|
||||||
>
|
|
||||||
{board.name}
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
</TourTarget>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useCallback, useState } from "react";
|
import { use, useCallback, useId, useState } from "react";
|
||||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||||
@@ -32,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||||
|
const previewId = useId();
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||||
@@ -58,6 +59,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
preloadWidgetEditModal();
|
preloadWidgetEditModal();
|
||||||
try {
|
try {
|
||||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||||
|
const originalState = state;
|
||||||
openWidgetEditModal(
|
openWidgetEditModal(
|
||||||
{
|
{
|
||||||
kind,
|
kind,
|
||||||
@@ -66,11 +68,14 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
onSuccessfulEdit: (value) => {
|
onSuccessfulEdit: (value) => {
|
||||||
setState(value);
|
setState(value);
|
||||||
},
|
},
|
||||||
|
onPreviewChange: setState,
|
||||||
|
onPreviewRestore: () => setState(originalState),
|
||||||
integrationData: integrationData.filter((integration) =>
|
integrationData: integrationData.filter((integration) =>
|
||||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: hasIntegrationSupport,
|
integrationSupport: hasIntegrationSupport,
|
||||||
settings,
|
settings,
|
||||||
|
focusTargetId: previewId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title(translate) {
|
title(translate) {
|
||||||
@@ -86,7 +91,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
} finally {
|
} finally {
|
||||||
setIsEditorLoading(false);
|
setIsEditorLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
|
||||||
|
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
setEditMode((editMode) => !editMode);
|
setEditMode((editMode) => !editMode);
|
||||||
@@ -110,7 +115,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
|
|||||||
@@ -130,6 +130,45 @@
|
|||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-board-widget] {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-board-widget] .panelHeader,
|
||||||
|
.widgetPanel[data-board-widget] .composerWrap {
|
||||||
|
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-board-widget] .composerWrap,
|
||||||
|
.widgetPanel[data-board-widget] .composer,
|
||||||
|
.widgetPanel[data-board-widget] .composerQuote,
|
||||||
|
.widgetPanel[data-board-widget] .pendingQuestionDock {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-board-widget] .suggestion,
|
||||||
|
.widgetPanel[data-board-widget] .runtimeSelectorTrigger,
|
||||||
|
.widgetPanel[data-board-widget] .composerContext,
|
||||||
|
.widgetPanel[data-board-widget] .providerQuotaTrigger {
|
||||||
|
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-board-widget] .suggestion:hover:not(:disabled),
|
||||||
|
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:hover:not(:disabled),
|
||||||
|
.widgetPanel[data-board-widget] .composerContext:hover:not(:disabled),
|
||||||
|
.widgetPanel[data-board-widget] .providerQuotaTrigger:hover:not(:disabled) {
|
||||||
|
background: rgb(from var(--mantine-color-default-hover) r g b / var(--opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetPanel[data-board-widget] .suggestion:focus-visible,
|
||||||
|
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:focus-visible,
|
||||||
|
.widgetPanel[data-board-widget] .composerContext:focus-visible,
|
||||||
|
.widgetPanel[data-board-widget] .providerQuotaTrigger:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.widgetState {
|
.widgetState {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -157,30 +196,12 @@
|
|||||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelIdentity {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelIdentityIcon {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelIdentityTitle,
|
|
||||||
.panelThreadTitle {
|
|
||||||
max-width: 14rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelActions {
|
.panelActions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newConversationButton {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelAction {
|
.panelAction {
|
||||||
width: 2.625rem;
|
width: 2.625rem;
|
||||||
height: 2.625rem;
|
height: 2.625rem;
|
||||||
@@ -207,21 +228,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newConversationButton {
|
|
||||||
width: 2.625rem;
|
|
||||||
max-width: 2.625rem;
|
|
||||||
height: 2.625rem;
|
|
||||||
padding-inline: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newConversationButton :global(.mantine-Button-section) {
|
|
||||||
margin-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newConversationButton :global(.mantine-Button-label) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
padding: 0.5rem 0.65rem 1rem;
|
padding: 0.5rem 0.65rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -277,8 +283,7 @@
|
|||||||
|
|
||||||
@container assistant-widget (max-width: 22rem) {
|
@container assistant-widget (max-width: 22rem) {
|
||||||
.panelAction,
|
.panelAction,
|
||||||
.historyButton,
|
.historyButton {
|
||||||
.newConversationButton {
|
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
max-width: 2.25rem;
|
max-width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
@@ -301,10 +306,6 @@
|
|||||||
.composerContextLabel {
|
.composerContextLabel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelThreadTitle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@container assistant-widget (max-width: 18rem) {
|
@container assistant-widget (max-width: 18rem) {
|
||||||
|
|||||||
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
|
|||||||
<Box className={classes.empty}>
|
<Box className={classes.empty}>
|
||||||
<Stack align="center" gap="lg" maw={560} w="100%">
|
<Stack align="center" gap="lg" maw={560} w="100%">
|
||||||
<Stack align="center" gap="xs" maw={430}>
|
<Stack align="center" gap="xs" maw={430}>
|
||||||
<ThemeIcon size={52} radius="xl" variant="light" color="red">
|
<ThemeIcon size={52} radius="xl" variant="light">
|
||||||
<IconRobot size={27} />
|
<IconRobot size={27} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xl" fw={700}>
|
<Text size="xl" fw={700}>
|
||||||
@@ -2957,7 +2957,6 @@ const Composer = (props: ComposerProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<ComposerPrimitive.Send asChild>
|
<ComposerPrimitive.Send asChild>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color="red"
|
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -3162,27 +3161,6 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
|||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantPanelIdentity = () => {
|
|
||||||
const t = useI18n("assistant");
|
|
||||||
const threadTitle = useAuiState((state) => state.threadListItem.title);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
|
|
||||||
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
|
|
||||||
<IconRobot size={16} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box miw={0}>
|
|
||||||
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
|
|
||||||
{t("title")}
|
|
||||||
</Text>
|
|
||||||
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
|
|
||||||
{threadTitle ?? t("newConversation")}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AssistantConversationSurface = ({
|
export const AssistantConversationSurface = ({
|
||||||
isRunning,
|
isRunning,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
@@ -3212,23 +3190,20 @@ export const AssistantConversationSurface = ({
|
|||||||
return (
|
return (
|
||||||
<AssistantDirectiveEntitiesProvider>
|
<AssistantDirectiveEntitiesProvider>
|
||||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||||
<AssistantPanelIdentity />
|
|
||||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||||
<ConversationHistory />
|
<ConversationHistory />
|
||||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||||
<AutoApprovalControl />
|
<AutoApprovalControl />
|
||||||
<Tooltip label={t("newConversation")}>
|
<Tooltip label={t("newConversation")}>
|
||||||
<ThreadListPrimitive.New asChild>
|
<ThreadListPrimitive.New asChild>
|
||||||
<Button
|
<ActionIcon
|
||||||
className={classes.newConversationButton}
|
className={classes.panelAction}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="compact-sm"
|
|
||||||
leftSection={<IconPlus size={17} />}
|
|
||||||
aria-label={t("newConversation")}
|
aria-label={t("newConversation")}
|
||||||
>
|
>
|
||||||
{t("newConversation")}
|
<IconPlus size={17} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
</ThreadListPrimitive.New>
|
</ThreadListPrimitive.New>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onExpand && (
|
{onExpand && (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
|
|||||||
const t = useI18n("assistant");
|
const t = useI18n("assistant");
|
||||||
return (
|
return (
|
||||||
<Group px="md" py="sm" wrap="nowrap">
|
<Group px="md" py="sm" wrap="nowrap">
|
||||||
<ThemeIcon color="red" variant="light" radius="xl">
|
<ThemeIcon variant="light" radius="xl">
|
||||||
<IconRobot size={17} />
|
<IconRobot size={17} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={1}>
|
<Stack gap={1}>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
|
|||||||
<Box className={classes.widgetPanel}>
|
<Box className={classes.widgetPanel}>
|
||||||
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
<Group wrap="nowrap" gap="xs" miw={0}>
|
<Group wrap="nowrap" gap="xs" miw={0}>
|
||||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
|
<ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
|
||||||
<IconRobot size={19} />
|
<IconRobot size={19} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack className={classes.compactCopy} gap={1}>
|
<Stack className={classes.compactCopy} gap={1}>
|
||||||
@@ -167,13 +167,7 @@ interface AssistantWidgetStateProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantWidgetState = ({
|
const AssistantWidgetState = ({ icon: Icon, title, description, color, children }: AssistantWidgetStateProps) => (
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
color = "red",
|
|
||||||
children,
|
|
||||||
}: AssistantWidgetStateProps) => (
|
|
||||||
<Center className={classes.widgetState} h="100%" p="md">
|
<Center className={classes.widgetState} h="100%" p="md">
|
||||||
<Stack align="center" gap="sm" ta="center" maw={360}>
|
<Stack align="center" gap="sm" ta="center" maw={360}>
|
||||||
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.canvas {
|
.canvas {
|
||||||
min-height: 8rem;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
aspect-ratio: 16 / 7;
|
aspect-ratio: 16 / 7;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
@@ -83,9 +84,3 @@
|
|||||||
gap: rem(1px);
|
gap: rem(1px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-xs) {
|
|
||||||
.canvas {
|
|
||||||
min-height: 7rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,12 @@ import classes from "./board-layout-thumbnail.module.css";
|
|||||||
interface BoardLayoutThumbnailProps {
|
interface BoardLayoutThumbnailProps {
|
||||||
preview: BoardPreviewData | null;
|
preview: BoardPreviewData | null;
|
||||||
label: string;
|
label: string;
|
||||||
|
fitFullLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxThumbnailRows = 12;
|
const maxThumbnailRows = 12;
|
||||||
|
|
||||||
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||||
if (!preview || !layout) {
|
if (!preview || !layout) {
|
||||||
return (
|
return (
|
||||||
@@ -26,6 +27,7 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elements = projectBoardLayout(preview, layout, layout);
|
const elements = projectBoardLayout(preview, layout, layout);
|
||||||
|
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||||
const roots = preview.sections
|
const roots = preview.sections
|
||||||
.filter((section) => section.kind === "empty")
|
.filter((section) => section.kind === "empty")
|
||||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||||
@@ -65,12 +67,12 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
|
|||||||
element.xOffset >= 0 &&
|
element.xOffset >= 0 &&
|
||||||
element.xOffset < columnCount &&
|
element.xOffset < columnCount &&
|
||||||
element.yOffset >= 0 &&
|
element.yOffset >= 0 &&
|
||||||
element.yOffset < maxThumbnailRows &&
|
element.yOffset < thumbnailRowLimit &&
|
||||||
element.width > 0 &&
|
element.width > 0 &&
|
||||||
element.height > 0,
|
element.height > 0,
|
||||||
);
|
);
|
||||||
const rowCount = Math.min(
|
const rowCount = Math.min(
|
||||||
maxThumbnailRows,
|
thumbnailRowLimit,
|
||||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.modalContent {
|
||||||
|
overflow: hidden;
|
||||||
|
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
|
||||||
|
border-radius: rem(24px);
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
|
||||||
|
box-shadow: 0 rem(18px) rem(56px) rgb(0 0 0 / 22%);
|
||||||
|
backdrop-filter: blur(rem(20px)) saturate(140%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBody {
|
||||||
|
padding: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
|
||||||
|
backdrop-filter: blur(rem(8px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--mantine-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadataBadge {
|
||||||
|
width: rem(22px);
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover .card,
|
||||||
|
.option:focus-visible .card,
|
||||||
|
.option[data-active="true"] .card {
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
ScrollArea,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
useMatches,
|
||||||
|
VisuallyHidden,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
|
import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useOptionalBoard } from "@homarr/boards/context";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||||
|
|
||||||
|
import classes from "./board-switcher.module.css";
|
||||||
|
|
||||||
|
export const boardSwitcherHotkey = "shift+c";
|
||||||
|
|
||||||
|
interface BoardSwitcherProps {
|
||||||
|
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||||
|
const t = useI18n("board.action.switcher");
|
||||||
|
const manageBoardsT = useI18n("management.page.board");
|
||||||
|
const currentBoard = useOptionalBoard();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const responsiveColumnCount = useMatches({ base: 1, sm: 2, lg: 3, xl: 4 });
|
||||||
|
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const {
|
||||||
|
data: boards = [],
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
} = clientApi.board.getManageOverview.useQuery(
|
||||||
|
{ fullPreview: true },
|
||||||
|
{
|
||||||
|
enabled: isOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const switcherBoards = useMemo(
|
||||||
|
() => [
|
||||||
|
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||||
|
...boards.filter((board) => board.id === currentBoard?.id),
|
||||||
|
],
|
||||||
|
[boards, currentBoard?.id],
|
||||||
|
);
|
||||||
|
const filteredBoards = useMemo(() => {
|
||||||
|
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||||
|
if (normalizedSearch.length === 0) return switcherBoards;
|
||||||
|
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||||
|
}, [search, switcherBoards]);
|
||||||
|
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||||
|
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
|
||||||
|
|
||||||
|
const openSwitcher = useCallback(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
const closeSwitcher = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
setActiveIndex(0);
|
||||||
|
}, []);
|
||||||
|
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||||
|
|
||||||
|
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || filteredBoards.length === 0) return;
|
||||||
|
const normalizedIndex = Math.min(activeIndex, filteredBoards.length - 1);
|
||||||
|
if (normalizedIndex !== activeIndex) {
|
||||||
|
setActiveIndex(normalizedIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
optionRefs.current[normalizedIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
}, [activeIndex, filteredBoards.length, isOpen]);
|
||||||
|
|
||||||
|
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const isSearchInput =
|
||||||
|
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
|
||||||
|
|
||||||
|
if (!isSearchInput && event.key === "Backspace") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSearch((current) => current.slice(0, -1));
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSearch((current) => `${current}${event.key}`);
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredBoards.length === 0) return;
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Element && target.closest("[data-board-switcher-option]")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
optionRefs.current[activeIndex]?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = activeIndex;
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
nextIndex = (activeIndex + 1) % filteredBoards.length;
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
nextIndex = (activeIndex - 1 + filteredBoards.length) % filteredBoards.length;
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
const nextRowIndex = activeIndex + columnCount;
|
||||||
|
nextIndex = nextRowIndex;
|
||||||
|
if (nextRowIndex >= filteredBoards.length) nextIndex = activeIndex % columnCount;
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
const previousRowIndex = activeIndex - columnCount;
|
||||||
|
if (previousRowIndex >= 0) {
|
||||||
|
nextIndex = previousRowIndex;
|
||||||
|
} else {
|
||||||
|
const lastRowStart = Math.floor((filteredBoards.length - 1) / columnCount) * columnCount;
|
||||||
|
const lastIndexInColumn = lastRowStart + activeIndex;
|
||||||
|
nextIndex = lastIndexInColumn;
|
||||||
|
if (lastIndexInColumn >= filteredBoards.length) nextIndex -= columnCount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex(nextIndex);
|
||||||
|
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus({ preventScroll: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = getBoardSwitcherResults({
|
||||||
|
availableBoardCount: switcherBoards.length,
|
||||||
|
filteredBoards,
|
||||||
|
isError,
|
||||||
|
isPending,
|
||||||
|
search,
|
||||||
|
t,
|
||||||
|
activeIndex,
|
||||||
|
closeSwitcher,
|
||||||
|
setActiveIndex,
|
||||||
|
optionRefs,
|
||||||
|
columnCount,
|
||||||
|
manageBoardsT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={isOpen}
|
||||||
|
onClose={closeSwitcher}
|
||||||
|
withCloseButton={false}
|
||||||
|
yOffset="15vh"
|
||||||
|
aria-label={t("title")}
|
||||||
|
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
|
||||||
|
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
|
||||||
|
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||||
|
classNames={{ content: classes.modalContent, body: classes.modalBody }}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
tabIndex={-1}
|
||||||
|
gap="xs"
|
||||||
|
onKeyDown={handleModalKeyDown}
|
||||||
|
className={classes.content}
|
||||||
|
aria-describedby="board-switcher-instructions"
|
||||||
|
>
|
||||||
|
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
data-board-switcher-search
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
setActiveIndex(0);
|
||||||
|
}}
|
||||||
|
leftSection={<IconSearch size="1rem" stroke={1.6} />}
|
||||||
|
placeholder={t("title")}
|
||||||
|
aria-label={t("title")}
|
||||||
|
aria-controls="board-switcher-results"
|
||||||
|
radius="xl"
|
||||||
|
classNames={{ input: classes.searchInput }}
|
||||||
|
/>
|
||||||
|
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
|
||||||
|
{results}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BoardSwitcherResultsProps {
|
||||||
|
availableBoardCount: number;
|
||||||
|
filteredBoards: RouterOutputs["board"]["getManageOverview"];
|
||||||
|
isError: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
search: string;
|
||||||
|
t: ReturnType<typeof useI18n<"board.action.switcher">>;
|
||||||
|
activeIndex: number;
|
||||||
|
closeSwitcher: () => void;
|
||||||
|
setActiveIndex: (index: number) => void;
|
||||||
|
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||||
|
columnCount: number;
|
||||||
|
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBoardSwitcherResults = ({
|
||||||
|
availableBoardCount,
|
||||||
|
filteredBoards,
|
||||||
|
isError,
|
||||||
|
isPending,
|
||||||
|
search,
|
||||||
|
t,
|
||||||
|
activeIndex,
|
||||||
|
closeSwitcher,
|
||||||
|
setActiveIndex,
|
||||||
|
optionRefs,
|
||||||
|
columnCount,
|
||||||
|
manageBoardsT,
|
||||||
|
}: BoardSwitcherResultsProps) => {
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("error")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableBoardCount === 0) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<ThemeIcon variant="light" color="gray" size="xl" radius="xl">
|
||||||
|
<IconLayoutBoard stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600}>{t("empty.title")}</Text>
|
||||||
|
<Text c="dimmed" size="sm" ta="center">
|
||||||
|
{t("empty.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredBoards.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("search.empty", { search })}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
|
||||||
|
{filteredBoards.map((board, index) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={board.id}
|
||||||
|
ref={(node) => {
|
||||||
|
optionRefs.current[index] = node;
|
||||||
|
}}
|
||||||
|
className={classes.option}
|
||||||
|
w="100%"
|
||||||
|
maw={240}
|
||||||
|
mx="auto"
|
||||||
|
component={Link}
|
||||||
|
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||||
|
data-active={index === activeIndex}
|
||||||
|
data-board-switcher-option
|
||||||
|
onClick={closeSwitcher}
|
||||||
|
onFocus={() => setActiveIndex(index)}
|
||||||
|
onPointerEnter={() => setActiveIndex(index)}
|
||||||
|
>
|
||||||
|
<Card padding={0} withBorder radius="lg" className={classes.card}>
|
||||||
|
<Card.Section>
|
||||||
|
<BoardLayoutThumbnail
|
||||||
|
preview={board.preview}
|
||||||
|
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||||
|
fitFullLayout
|
||||||
|
/>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section withBorder px="sm" py={6}>
|
||||||
|
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||||
|
<Text fw={600} size="sm" truncate>
|
||||||
|
{board.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{board.isHome && (
|
||||||
|
<Tooltip label={manageBoardsT("action.setHomeBoard.badge.tooltip")}>
|
||||||
|
<Badge
|
||||||
|
className={classes.metadataBadge}
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
aria-label={manageBoardsT("action.setHomeBoard.badge.label")}
|
||||||
|
>
|
||||||
|
<IconHomeFilled size="0.7rem" />
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{board.isMobileHome && (
|
||||||
|
<Tooltip label={manageBoardsT("action.setMobileHomeBoard.badge.tooltip")}>
|
||||||
|
<Badge
|
||||||
|
className={classes.metadataBadge}
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
aria-label={manageBoardsT("action.setMobileHomeBoard.badge.label")}
|
||||||
|
>
|
||||||
|
<IconDeviceMobile size="0.7rem" />
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{board.creator && (
|
||||||
|
<Tooltip label={board.creator.name ?? manageBoardsT("preview.unknownCreator")}>
|
||||||
|
<UserAvatar user={board.creator} size="xs" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -30,6 +30,13 @@ interface UpdateItemIntegrations {
|
|||||||
newIntegrations: string[];
|
newIntegrations: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdateItemSettings {
|
||||||
|
itemId: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
advancedOptions: BoardItemAdvancedOptions;
|
||||||
|
integrationIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const useItemActions = () => {
|
export const useItemActions = () => {
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
@@ -77,6 +84,20 @@ export const useItemActions = () => {
|
|||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateItemSettings = useCallback(
|
||||||
|
({ itemId, options, advancedOptions, integrationIds }: UpdateItemSettings) => {
|
||||||
|
updateBoard((previous) => ({
|
||||||
|
...previous,
|
||||||
|
items: previous.items.map((item) => {
|
||||||
|
if (item.id !== itemId || !("integrationIds" in item)) return item;
|
||||||
|
|
||||||
|
return { ...item, options, advancedOptions, integrationIds };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateBoard],
|
||||||
|
);
|
||||||
|
|
||||||
const moveAndResizeItem = useCallback(
|
const moveAndResizeItem = useCallback(
|
||||||
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
||||||
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
||||||
@@ -105,6 +126,7 @@ export const useItemActions = () => {
|
|||||||
updateItemOptions,
|
updateItemOptions,
|
||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
|
updateItemSettings,
|
||||||
duplicateItem,
|
duplicateItem,
|
||||||
createItem,
|
createItem,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ const LoadedBoardItemContent = ({
|
|||||||
role={isPreview ? "region" : undefined}
|
role={isPreview ? "region" : undefined}
|
||||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||||
data-advanced-focus-surface={isPreview || undefined}
|
data-advanced-focus-surface={isPreview || undefined}
|
||||||
|
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
|
||||||
data-grid-item-content
|
data-grid-item-content
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
p={isAdvanced ? undefined : 0}
|
p={isAdvanced ? undefined : 0}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core";
|
|||||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { getWidgetName } from "@homarr/definitions";
|
import { getWidgetName } from "@homarr/definitions";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
@@ -33,6 +34,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
|||||||
};
|
};
|
||||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const board = useRequiredBoard();
|
||||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
const tItem = useI18n("item");
|
const tItem = useI18n("item");
|
||||||
@@ -40,8 +42,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const openMoveModal = useOpenItemMoveModal();
|
const openMoveModal = useOpenItemMoveModal();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
|
||||||
useItemActions();
|
|
||||||
const { integrations: integrationData, section } = useSectionContext();
|
const { integrations: integrationData, section } = useSectionContext();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
|
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
|
||||||
@@ -55,6 +56,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
}, [item, resetErrorBoundary]);
|
}, [item, resetErrorBoundary]);
|
||||||
|
|
||||||
const openEditModal = () => {
|
const openEditModal = () => {
|
||||||
|
const originalSettings = {
|
||||||
|
options: item.options,
|
||||||
|
advancedOptions: item.advancedOptions,
|
||||||
|
integrationIds: item.integrationIds,
|
||||||
|
};
|
||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
@@ -64,19 +70,14 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
integrationIds: item.integrationIds,
|
integrationIds: item.integrationIds,
|
||||||
},
|
},
|
||||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
onPreviewChange: (value) => {
|
||||||
updateItemOptions({
|
updateItemSettings({ itemId: item.id, ...value });
|
||||||
itemId: item.id,
|
},
|
||||||
newOptions: options,
|
onPreviewRestore: () => {
|
||||||
});
|
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||||
updateItemAdvancedOptions({
|
},
|
||||||
itemId: item.id,
|
onSuccessfulEdit: (value) => {
|
||||||
newAdvancedOptions: advancedOptions,
|
updateItemSettings({ itemId: item.id, ...value });
|
||||||
});
|
|
||||||
updateItemIntegrations({
|
|
||||||
itemId: item.id,
|
|
||||||
newIntegrations: integrationIds,
|
|
||||||
});
|
|
||||||
refResetErrorBoundaryOnNextRender.current = true;
|
refResetErrorBoundaryOnNextRender.current = true;
|
||||||
},
|
},
|
||||||
onIntegrationSaved: resetErrorBoundary,
|
onIntegrationSaved: resetErrorBoundary,
|
||||||
@@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
|||||||
integrationSupport: "supportedIntegrations" in definition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
|
boardId: board.id,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { getLayoutRowCount } from "../layout";
|
import { getLayoutRowCount } from "../layout";
|
||||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||||
|
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
||||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||||
@@ -57,26 +58,29 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
const tContainer = useI18n("section.container");
|
const tContainer = useI18n("section.container");
|
||||||
const tMoveResize = useI18n("item.moveResize");
|
const tMoveResize = useI18n("item.moveResize");
|
||||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||||
|
const registeredGridEditors = useRegisteredGridEditors();
|
||||||
const minimumSize = useMemo(
|
const minimumSize = useMemo(
|
||||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||||
[board, currentLayoutId, entry],
|
[board, currentLayoutId, entry],
|
||||||
);
|
);
|
||||||
const targets = useMemo(
|
const targets = getMoveTargets(
|
||||||
() =>
|
board,
|
||||||
getMoveTargets(board, currentLayoutId, entry, {
|
currentLayoutId,
|
||||||
canvas: tLandmark("canvas"),
|
entry,
|
||||||
container: tContainer("action.create"),
|
{
|
||||||
leftRail: tLandmark("leftRail"),
|
canvas: tLandmark("canvas"),
|
||||||
rightRail: tLandmark("rightRail"),
|
container: tContainer("action.create"),
|
||||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
leftRail: tLandmark("leftRail"),
|
||||||
located: (name, location, index) =>
|
rightRail: tLandmark("rightRail"),
|
||||||
tMoveResize("target.located", {
|
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||||
name,
|
located: (name, location, index) =>
|
||||||
location,
|
tMoveResize("target.located", {
|
||||||
index: String(index),
|
name,
|
||||||
}),
|
location,
|
||||||
}),
|
index: String(index),
|
||||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
}),
|
||||||
|
},
|
||||||
|
registeredGridEditors,
|
||||||
);
|
);
|
||||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||||
@@ -252,6 +256,7 @@ export const getMoveTargets = (
|
|||||||
layoutId: string,
|
layoutId: string,
|
||||||
entry: MovableEntry,
|
entry: MovableEntry,
|
||||||
labels: MoveTargetLabels,
|
labels: MoveTargetLabels,
|
||||||
|
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
|
||||||
): MoveTarget[] => {
|
): MoveTarget[] => {
|
||||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||||
|
|
||||||
@@ -260,13 +265,15 @@ export const getMoveTargets = (
|
|||||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||||
const lane = getRootSectionLane(section.xOffset);
|
const lane = getRootSectionLane(section.xOffset);
|
||||||
if (columnCount === 0) return [];
|
if (columnCount === 0) return [];
|
||||||
|
const registeredEditor = registeredGridEditors?.get(section.id);
|
||||||
|
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
|
||||||
return minimum.width <= columnCount
|
return minimum.width <= columnCount
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: section.id,
|
id: section.id,
|
||||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||||
columnCount,
|
columnCount,
|
||||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ const ItemSelectModalContent = ({
|
|||||||
integrationData: [],
|
integrationData: [],
|
||||||
integrationSupport: false,
|
integrationSupport: false,
|
||||||
settings,
|
settings,
|
||||||
|
boardId: board.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||||
@@ -346,6 +347,7 @@ const ItemSelectModalContent = ({
|
|||||||
integrationData: availableIntegrations,
|
integrationData: availableIntegrations,
|
||||||
integrationSupport: hasIntegrationSupport,
|
integrationSupport: hasIntegrationSupport,
|
||||||
settings,
|
settings,
|
||||||
|
boardId: board.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
|||||||
defaultTitle(t) {
|
defaultTitle(t) {
|
||||||
return t("item.edit.title");
|
return t("item.edit.title");
|
||||||
},
|
},
|
||||||
|
presentation: "inspector",
|
||||||
|
withOverlay: false,
|
||||||
size: modalSizeForm,
|
size: modalSizeForm,
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
});
|
});
|
||||||
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
|
|||||||
const tCommon = useI18n("common.action");
|
const tCommon = useI18n("common.action");
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
const { updateItemSettings } = useItemActions();
|
||||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||||
enabled: hasSupportedIntegrations,
|
enabled: hasSupportedIntegrations,
|
||||||
@@ -154,19 +154,27 @@ export const WidgetContextMenu = ({
|
|||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
const openEditModal = useCallback(() => {
|
const openEditModal = useCallback(() => {
|
||||||
|
const originalSettings = {
|
||||||
|
options: item.options,
|
||||||
|
advancedOptions: item.advancedOptions,
|
||||||
|
integrationIds: item.integrationIds,
|
||||||
|
};
|
||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
definition,
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options,
|
||||||
integrationIds: item.integrationIds,
|
integrationIds: item.integrationIds,
|
||||||
},
|
},
|
||||||
|
onPreviewChange: (value) => {
|
||||||
|
updateItemSettings({ itemId: item.id, ...value });
|
||||||
|
},
|
||||||
|
onPreviewRestore: () => {
|
||||||
|
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||||
|
},
|
||||||
onSuccessfulEdit: (editResult) => {
|
onSuccessfulEdit: (editResult) => {
|
||||||
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
|
|
||||||
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
|
|
||||||
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
|
|
||||||
persistBoard((previous) => ({
|
persistBoard((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
items: previous.items.map((boardItem) =>
|
items: previous.items.map((boardItem) =>
|
||||||
@@ -189,6 +197,8 @@ export const WidgetContextMenu = ({
|
|||||||
),
|
),
|
||||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||||
settings,
|
settings,
|
||||||
|
itemId: item.id,
|
||||||
|
boardId: board.id,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -197,16 +207,16 @@ export const WidgetContextMenu = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
board.id,
|
||||||
definition,
|
definition,
|
||||||
integrationData,
|
integrationData,
|
||||||
item,
|
item,
|
||||||
handleRefetch,
|
handleRefetch,
|
||||||
openModal,
|
openModal,
|
||||||
|
options,
|
||||||
persistBoard,
|
persistBoard,
|
||||||
settings,
|
settings,
|
||||||
updateItemAdvancedOptions,
|
updateItemSettings,
|
||||||
updateItemIntegrations,
|
|
||||||
updateItemOptions,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
The board uses fixed logical units:
|
The board uses fixed logical units:
|
||||||
|
|
||||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
|
||||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
|
||||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
- the canvas is zoomed once with one uniform scale derived from its available width.
|
||||||
|
|
||||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* individual cards never change their internal layout at different viewports.
|
* individual cards never change their internal layout at different viewports.
|
||||||
*/
|
*/
|
||||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||||
export const LOGICAL_GRID_GAP = 24;
|
export const LOGICAL_GRID_GAP = 12;
|
||||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||||
|
|
||||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
* zoom and enlarges everything together.
|
* zoom and enlarges everything together.
|
||||||
*/
|
*/
|
||||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||||
|
/* Keep a subtle boundary inset without widening the visual gap too much. */
|
||||||
|
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
|
||||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
|
|||||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
import {
|
||||||
|
getBoardLaneColumnCount,
|
||||||
|
getInitialBoardLogicalHeight,
|
||||||
|
getLogicalTrackSize,
|
||||||
|
getRootSectionForLane,
|
||||||
|
} from "../index";
|
||||||
|
|
||||||
describe("board lanes", () => {
|
describe("board lanes", () => {
|
||||||
test("gutters consume columns from the main canvas", () => {
|
test("gutters consume columns from the main canvas", () => {
|
||||||
@@ -126,6 +131,6 @@ describe("board lanes", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -9,18 +9,18 @@ import {
|
|||||||
} from "../index";
|
} from "../index";
|
||||||
|
|
||||||
describe("fixed dashboard geometry", () => {
|
describe("fixed dashboard geometry", () => {
|
||||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
|
||||||
expect(getLogicalTrackSize(1)).toBe(200);
|
expect(getLogicalTrackSize(1)).toBe(200);
|
||||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
expect(getLogicalTrackSize(0.5)).toBe(94);
|
||||||
expect(getLogicalTrackSize(2)).toBe(424);
|
expect(getLogicalTrackSize(2)).toBe(412);
|
||||||
expect(getLogicalTrackSize(3)).toBe(648);
|
expect(getLogicalTrackSize(3)).toBe(624);
|
||||||
|
|
||||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 224,
|
left: 212,
|
||||||
top: 448,
|
top: 424,
|
||||||
width: 424,
|
width: 412,
|
||||||
height: 648,
|
height: 624,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ describe("fixed dashboard geometry", () => {
|
|||||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
||||||
|
|
||||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
|
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
|
||||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -46,6 +46,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
<Card
|
<Card
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
classes.itemCard,
|
classes.itemCard,
|
||||||
|
classes.containerCard,
|
||||||
options.customCssClasses.join(" "),
|
options.customCssClasses.join(" "),
|
||||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||||
)}
|
)}
|
||||||
@@ -56,7 +57,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
root: {
|
root: {
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
"--border-color": options.borderColor || undefined,
|
"--container-border-color": options.borderColor || undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
@@ -66,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
className={classes.containerToggle}
|
className={classes.containerToggle}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={isVisuallyCollapsed ? 0 : -24}
|
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||||
left={0}
|
left={0}
|
||||||
w="100%"
|
w="100%"
|
||||||
h={isVisuallyCollapsed ? "100%" : 24}
|
h={isVisuallyCollapsed ? "100%" : 24}
|
||||||
@@ -103,8 +104,9 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||||
<Badge
|
<Badge
|
||||||
|
className={classes.containerLabel}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={-24}
|
top="calc(var(--mantine-spacing-xs) * -1)"
|
||||||
left={labelLeft}
|
left={labelLeft}
|
||||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||||
size="md"
|
size="md"
|
||||||
@@ -113,12 +115,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
c="var(--mantine-color-text)"
|
c="var(--mantine-color-text)"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 9,
|
zIndex: 9,
|
||||||
overflow: "hidden",
|
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
backgroundColor: "var(--background-color)",
|
|
||||||
borderColor: "var(--border-color)",
|
|
||||||
}}
|
}}
|
||||||
title={options.title}
|
title={options.title}
|
||||||
data-board-container-label
|
data-board-container-label
|
||||||
@@ -128,8 +125,9 @@ export const BoardContainerSection = ({ section }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{options.showOpenAll && !isEditMode && (
|
{options.showOpenAll && !isEditMode && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
className={classes.containerAction}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={isVisuallyCollapsed ? "50%" : -24}
|
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||||
right={8}
|
right={8}
|
||||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||||
variant={options.collapsible ? "subtle" : "default"}
|
variant={options.collapsible ? "subtle" : "default"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
|
|
||||||
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
|
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
|
||||||
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
|
import { useRegisteredGridEditors } from "./grid-editor-registry";
|
||||||
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||||
|
|
||||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||||
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
|||||||
const GridEditor = resolvedEditorModule?.default ?? null;
|
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridEditorRegistryProvider>
|
<>
|
||||||
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
||||||
{isEditMode && Provider && GridEditor && (
|
{isEditMode && Provider && GridEditor && (
|
||||||
<Provider>
|
<Provider>
|
||||||
<RegisteredGridEditors GridEditor={GridEditor} />
|
<RegisteredGridEditors GridEditor={GridEditor} />
|
||||||
</Provider>
|
</Provider>
|
||||||
)}
|
)}
|
||||||
</GridEditorRegistryProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ describe("dnd grid coordinates", () => {
|
|||||||
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
||||||
const scale = 0.75;
|
const scale = 0.75;
|
||||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||||
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
|
const initialShape = {
|
||||||
|
left: target.left,
|
||||||
|
top: target.top,
|
||||||
|
width: getLogicalTrackSize(2) * scale,
|
||||||
|
height: 200 * scale,
|
||||||
|
};
|
||||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface GridEditorRenderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
||||||
|
placementMaxRowCount: number | null;
|
||||||
host: HTMLElement;
|
host: HTMLElement;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
.board-grid-entry[data-board-item-selected="true"]::after {
|
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: var(--board-grid-item-inset, 0px);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
content: "";
|
content: "";
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||||
@@ -121,6 +121,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
|
|
||||||
.board-grid-placeholder {
|
.board-grid-placeholder {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||||
border: 2px dashed var(--mantine-primary-color-filled);
|
border: 2px dashed var(--mantine-primary-color-filled);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
@@ -139,6 +140,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
|
|
||||||
.board-grid-resize-outline {
|
.board-grid-resize-outline {
|
||||||
z-index: 1002;
|
z-index: 1002;
|
||||||
|
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||||
border: 2px solid var(--mantine-primary-color-filled);
|
border: 2px solid var(--mantine-primary-color-filled);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
@@ -153,17 +155,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
|
|
||||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
|
||||||
outline-offset: 4px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-section-kind="empty"][data-rail-placement="main"]
|
|
||||||
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
|
||||||
outline-color: var(--mantine-color-red-filled);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-board-container-collapsed]
|
[data-board-container-collapsed]
|
||||||
> [data-board-container-body]
|
> [data-board-container-body]
|
||||||
> [data-section-id]
|
> [data-section-id]
|
||||||
@@ -240,11 +231,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||||
top: -0.25rem;
|
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||||
bottom: -0.25rem;
|
bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||||
@@ -258,11 +249,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||||
right: -0.25rem;
|
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||||
left: -0.25rem;
|
left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||||
@@ -275,42 +266,30 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||||
top: -0.625rem;
|
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
right: -0.625rem;
|
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||||
right: -0.625rem;
|
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
bottom: -0.625rem;
|
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||||
bottom: -0.625rem;
|
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
left: -0.625rem;
|
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||||
top: -0.625rem;
|
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
left: -0.625rem;
|
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
|
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
|
||||||
so it doesn't sit on top of the container menu button anchored in this same corner. */
|
so it doesn't sit on top of the container menu button anchored in this same corner. */
|
||||||
clip-path: polygon(
|
clip-path: polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 4px 4px, 4px 40px, 40px 40px, 40px 4px, 4px 4px);
|
||||||
evenodd,
|
|
||||||
0 0,
|
|
||||||
100% 0,
|
|
||||||
100% 100%,
|
|
||||||
0 100%,
|
|
||||||
0 0,
|
|
||||||
4px 4px,
|
|
||||||
4px 40px,
|
|
||||||
40px 40px,
|
|
||||||
40px 4px,
|
|
||||||
4px 4px
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -46,12 +46,14 @@
|
|||||||
|
|
||||||
.contentMount {
|
.contentMount {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: var(--board-grid-item-inset, 0px);
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staticItem:has([data-board-container-label]) {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.editorEntry {
|
.editorEntry {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ export const SectionGrid = ({
|
|||||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||||
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
||||||
|
let placementMaxRowCount = maxRowCount;
|
||||||
|
if (railPlacement !== "main") {
|
||||||
|
placementMaxRowCount = minimumViewportRowCount;
|
||||||
|
}
|
||||||
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
|
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
|
||||||
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
||||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||||
@@ -175,6 +179,7 @@ export const SectionGrid = ({
|
|||||||
columnCount,
|
columnCount,
|
||||||
rowCount,
|
rowCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
|
placementMaxRowCount,
|
||||||
placements: displayPlacements,
|
placements: displayPlacements,
|
||||||
transactionPlacements: placements,
|
transactionPlacements: placements,
|
||||||
className: editorClassName,
|
className: editorClassName,
|
||||||
@@ -190,6 +195,7 @@ export const SectionGrid = ({
|
|||||||
entryElementStore,
|
entryElementStore,
|
||||||
isInteractionDisabled,
|
isInteractionDisabled,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
|
placementMaxRowCount,
|
||||||
placements,
|
placements,
|
||||||
rowCount,
|
rowCount,
|
||||||
section,
|
section,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, test } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
|
|
||||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||||
|
|
||||||
describe("grid preview geometry", () => {
|
describe("grid preview geometry", () => {
|
||||||
@@ -25,8 +27,12 @@ describe("grid preview geometry", () => {
|
|||||||
|
|
||||||
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
|
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
|
||||||
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
|
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
|
||||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
|
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
|
||||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
|
`${-2 * LOGICAL_GRID_PITCH}px`,
|
||||||
|
);
|
||||||
|
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe(
|
||||||
|
`${2 * LOGICAL_GRID_PITCH}px`,
|
||||||
|
);
|
||||||
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
||||||
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
||||||
|
|
||||||
@@ -57,10 +63,10 @@ describe("grid preview geometry", () => {
|
|||||||
|
|
||||||
const active = elements.get("active");
|
const active = elements.get("active");
|
||||||
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
|
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
|
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
|
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
|
||||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
|
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
|
||||||
expect(active?.dataset.gridW).toBe("3");
|
expect(active?.dataset.gridW).toBe("3");
|
||||||
expect(active?.dataset.gridH).toBe("4");
|
expect(active?.dataset.gridH).toBe("4");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||||
|
|
||||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||||
|
|
||||||
describe("grid resize outline", () => {
|
describe("grid resize outline", () => {
|
||||||
@@ -28,8 +30,8 @@ describe("grid resize outline", () => {
|
|||||||
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
||||||
expect(outline?.style.left).toBe("0px");
|
expect(outline?.style.left).toBe("0px");
|
||||||
expect(outline?.style.top).toBe("0px");
|
expect(outline?.style.top).toBe("0px");
|
||||||
expect(outline?.style.width).toBe("200px");
|
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
|
||||||
expect(outline?.style.height).toBe("424px");
|
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
|
||||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||||
|
|
||||||
controller.schedule(null);
|
controller.schedule(null);
|
||||||
|
|||||||
@@ -11,6 +11,18 @@
|
|||||||
border-color: var(--border-color) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.containerCard {
|
||||||
|
@mixin dark {
|
||||||
|
--container-default-border-color: var(--mantine-color-dark-4);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
--container-default-border-color: var(--mantine-color-gray-3);
|
||||||
|
}
|
||||||
|
--border-color: rgb(
|
||||||
|
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.itemCard:global(.iframe-wrapper) {
|
.itemCard:global(.iframe-wrapper) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
@@ -26,8 +38,8 @@
|
|||||||
z-index: 9;
|
z-index: 9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color) !important;
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerToggle :global(.mantine-Button-inner) {
|
.containerToggle :global(.mantine-Button-inner) {
|
||||||
@@ -42,6 +54,24 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.containerLabel,
|
||||||
|
.containerAction {
|
||||||
|
background-color: var(--background-color) !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerLabel {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerLabel :global(.mantine-Badge-label) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.containerBody {
|
.containerBody {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
|
|||||||
copySelectedItems: () => Promise<void>;
|
copySelectedItems: () => Promise<void>;
|
||||||
pasteItems: () => Promise<void>;
|
pasteItems: () => Promise<void>;
|
||||||
removeSelectedItems: () => void;
|
removeSelectedItems: () => void;
|
||||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||||
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
|
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
|
||||||
|
|
||||||
const moveSelectedItemsToSection = useCallback(
|
const moveSelectedItemsToSection = useCallback(
|
||||||
(targetSectionId: string) => {
|
(targetSectionId: string, maxRowCount?: number | null) => {
|
||||||
if (selectedItemIds.size === 0) return;
|
if (selectedItemIds.size === 0) return;
|
||||||
|
|
||||||
const idsToMove = new Set(selectedItemIds);
|
const idsToMove = new Set(selectedItemIds);
|
||||||
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
|||||||
targetSection.kind === "container"
|
targetSection.kind === "container"
|
||||||
? (containerLayout?.width ?? 0)
|
? (containerLayout?.width ?? 0)
|
||||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||||
const rowCount = containerLayout?.height ?? 9999;
|
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
|
||||||
if (columnCount === 0 || rowCount === 0) return previous;
|
if (columnCount === 0 || rowCount === 0) return previous;
|
||||||
|
|
||||||
const existingContainers = previous.sections
|
const existingContainers = previous.sections
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||||
|
|
||||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
|
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
|
||||||
import { useBoardSelection } from "./board-selection-context";
|
import { useBoardSelection } from "./board-selection-context";
|
||||||
|
|
||||||
const MAX_AVATARS_DISPLAYED = 5;
|
const MAX_AVATARS_DISPLAYED = 5;
|
||||||
@@ -40,6 +41,7 @@ export const BoardSelectionToolbar = () => {
|
|||||||
moveSelectedItemsToSection,
|
moveSelectedItemsToSection,
|
||||||
} = useBoardSelection();
|
} = useBoardSelection();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const registeredGridEditors = useRegisteredGridEditors();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -96,7 +98,15 @@ export const BoardSelectionToolbar = () => {
|
|||||||
.at(0);
|
.at(0);
|
||||||
|
|
||||||
const placementOptions = [
|
const placementOptions = [
|
||||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
...(mainCanvasSection
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: mainCanvasSection.id,
|
||||||
|
label: t("item.create.destination.mainCanvas"),
|
||||||
|
maxRowCount: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...board.sections.flatMap((section) => {
|
...board.sections.flatMap((section) => {
|
||||||
if (section.kind !== "empty") return [];
|
if (section.kind !== "empty") return [];
|
||||||
const lane = getRootSectionLane(section.xOffset);
|
const lane = getRootSectionLane(section.xOffset);
|
||||||
@@ -105,6 +115,7 @@ export const BoardSelectionToolbar = () => {
|
|||||||
{
|
{
|
||||||
value: section.id,
|
value: section.id,
|
||||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||||
|
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
@@ -113,6 +124,7 @@ export const BoardSelectionToolbar = () => {
|
|||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
value: section.id,
|
value: section.id,
|
||||||
label: section.options.title.trim() || t("section.container.untitled"),
|
label: section.options.title.trim() || t("section.container.untitled"),
|
||||||
|
maxRowCount: section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.height,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -244,7 +256,11 @@ export const BoardSelectionToolbar = () => {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||||
{placementOptions.map((opt) => (
|
{placementOptions.map((opt) => (
|
||||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
<Menu.Item
|
||||||
|
key={opt.value}
|
||||||
|
disabled={opt.maxRowCount === undefined}
|
||||||
|
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -66,16 +66,6 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "board-switcher",
|
|
||||||
title: t("switcher.title"),
|
|
||||||
content: (
|
|
||||||
<TourStepContent
|
|
||||||
description={t("switcher.description")}
|
|
||||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "board-user-menu",
|
id: "board-user-menu",
|
||||||
title: t("userMenu.title"),
|
title: t("userMenu.title"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconHome,
|
IconHome,
|
||||||
IconLogin,
|
IconLogin,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
|
IconReplace,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
IconTool,
|
IconTool,
|
||||||
@@ -22,6 +23,7 @@ import { Link } from "@homarr/ui";
|
|||||||
|
|
||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||||
|
import { BoardSwitcher } from "./board/board-switcher";
|
||||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||||
@@ -41,6 +43,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
|||||||
|
|
||||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||||
const t = useI18n("common.userAvatar.menu");
|
const t = useI18n("common.userAvatar.menu");
|
||||||
|
const tBoard = useI18n("board");
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
@@ -56,98 +59,111 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
|||||||
}, [logoutUrl]);
|
}, [logoutUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
<BoardSwitcher>
|
||||||
<Menu width={300} withinPortal keepMounted>
|
{({ open, preload, hotkey }) => (
|
||||||
<Menu.Dropdown>
|
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
<Menu width={300} withinPortal keepMounted>
|
||||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
<Menu.Dropdown>
|
||||||
{t("homeBoard")}
|
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||||
</Menu.Item>
|
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||||
<Menu.Divider />
|
{t("homeBoard")}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
|
||||||
<CurrentColorSchemeCombobox />
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
|
||||||
<CurrentLanguageCombobox />
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
|
||||||
{Boolean(session.data) && (
|
|
||||||
<>
|
|
||||||
{assistant?.enabled && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={
|
|
||||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
|
||||||
}
|
|
||||||
rightSection={
|
|
||||||
assistant.unreadCount > 0 ? (
|
|
||||||
<Badge size="sm" variant="filled" color="red" circle>
|
|
||||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
|
||||||
</Badge>
|
|
||||||
) : assistant.isRunning ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("assistantThinking")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={assistant.open}
|
|
||||||
>
|
|
||||||
{t("assistant")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
leftSection={<IconReplace size="1rem" />}
|
||||||
href={`/manage/users/${session.data?.user.id}/general`}
|
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
||||||
leftSection={<IconSettings size="1rem" />}
|
onClick={open}
|
||||||
|
onFocus={preload}
|
||||||
|
onPointerEnter={preload}
|
||||||
>
|
>
|
||||||
{t("preferences")}
|
{tBoard("action.switch")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||||
{t("management")}
|
<CurrentColorSchemeCombobox />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{isDockerEnabled && (
|
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
<CurrentLanguageCombobox />
|
||||||
{invariantTechnicalLabels.docker}
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
{Boolean(session.data) && (
|
||||||
|
<>
|
||||||
|
{assistant?.enabled && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
assistant.unreadCount > 0 ? (
|
||||||
|
<Badge size="sm" variant="filled" color="red" circle>
|
||||||
|
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||||
|
</Badge>
|
||||||
|
) : assistant.isRunning ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("assistantThinking")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={assistant.open}
|
||||||
|
>
|
||||||
|
{t("assistant")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/users/${session.data?.user.id}/general`}
|
||||||
|
leftSection={<IconSettings size="1rem" />}
|
||||||
|
>
|
||||||
|
{t("preferences")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||||
|
{t("management")}
|
||||||
|
</Menu.Item>
|
||||||
|
{isDockerEnabled && (
|
||||||
|
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||||
|
{invariantTechnicalLabels.docker}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
{session.status === "authenticated" ? (
|
||||||
|
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||||
|
{t("logout")}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||||
|
{t("login")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
</>
|
</Menu.Dropdown>
|
||||||
)}
|
<Menu.Target>
|
||||||
<Menu.Divider />
|
<Indicator
|
||||||
{session.status === "authenticated" ? (
|
inline
|
||||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||||
{t("logout")}
|
color="red"
|
||||||
</Menu.Item>
|
size={20}
|
||||||
) : (
|
offset={4}
|
||||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
label={
|
||||||
{t("login")}
|
assistant?.isRunning ? (
|
||||||
</Menu.Item>
|
<Loader type="bars" color="white" size={10} />
|
||||||
)}
|
) : assistant?.unreadCount ? (
|
||||||
</Menu.Dropdown>
|
assistant.unreadCount > 99 ? (
|
||||||
<Menu.Target>
|
"99+"
|
||||||
<Indicator
|
) : (
|
||||||
inline
|
assistant.unreadCount
|
||||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
)
|
||||||
color="red"
|
) : undefined
|
||||||
size={20}
|
}
|
||||||
offset={4}
|
>
|
||||||
label={
|
{children}
|
||||||
assistant?.isRunning ? (
|
</Indicator>
|
||||||
<Loader type="bars" color="white" size={10} />
|
</Menu.Target>
|
||||||
) : assistant?.unreadCount ? (
|
</Menu>
|
||||||
assistant.unreadCount > 99 ? (
|
)}
|
||||||
"99+"
|
</BoardSwitcher>
|
||||||
) : (
|
|
||||||
assistant.unreadCount
|
|
||||||
)
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Indicator>
|
|
||||||
</Menu.Target>
|
|
||||||
</Menu>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 651 KiB |
@@ -86,6 +86,7 @@ interface BoardItemPlacementRectangle {
|
|||||||
|
|
||||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||||
const maxManageOverviewPreviewRows = 12;
|
const maxManageOverviewPreviewRows = 12;
|
||||||
|
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||||
|
|
||||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||||
@@ -225,7 +226,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||||
@@ -306,7 +307,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -331,7 +332,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||||
gte(sectionLayouts.xOffset, 0),
|
gte(sectionLayouts.xOffset, 0),
|
||||||
gte(sectionLayouts.yOffset, 0),
|
gte(sectionLayouts.yOffset, 0),
|
||||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(sectionLayouts.width, 0),
|
gt(sectionLayouts.width, 0),
|
||||||
gt(sectionLayouts.height, 0),
|
gt(sectionLayouts.height, 0),
|
||||||
),
|
),
|
||||||
@@ -366,7 +367,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||||
gte(itemLayouts.xOffset, 0),
|
gte(itemLayouts.xOffset, 0),
|
||||||
gte(itemLayouts.yOffset, 0),
|
gte(itemLayouts.yOffset, 0),
|
||||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||||
gt(itemLayouts.width, 0),
|
gt(itemLayouts.width, 0),
|
||||||
gt(itemLayouts.height, 0),
|
gt(itemLayouts.height, 0),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
|
|||||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns one compact base-layout preview for each accessible board", async () => {
|
test("returns compact previews by default and full previews on request", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
@@ -487,6 +487,10 @@ describe("getManageOverview", () => {
|
|||||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
||||||
expect(result[0]?.preview?.sections).toHaveLength(2);
|
expect(result[0]?.preview?.sections).toHaveLength(2);
|
||||||
|
|
||||||
|
const fullResult = await caller.getManageOverview({ fullPreview: true });
|
||||||
|
|
||||||
|
expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("includes app and widget icons in compact previews", async () => {
|
test("includes app and widget icons in compact previews", async () => {
|
||||||
|
|||||||
@@ -514,6 +514,9 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
|||||||
|
|
||||||
interface DemoWidget {
|
interface DemoWidget {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
|
section?: "right";
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
needsIntegration: boolean;
|
needsIntegration: boolean;
|
||||||
@@ -562,9 +565,9 @@ const demoApps = [
|
|||||||
href: "https://portainer.io",
|
href: "https://portainer.io",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Home Assistant",
|
name: "Uptime Kuma",
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
|
||||||
href: "https://home-assistant.io",
|
href: "https://uptime.kuma.pet",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Nextcloud",
|
name: "Nextcloud",
|
||||||
@@ -583,69 +586,163 @@ const demoApps = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
|
||||||
// Row 1: calendar + downloads + clock = 12
|
// Daily focus
|
||||||
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
|
{ kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
|
||||||
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
|
|
||||||
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
|
|
||||||
// Row 2: healthMonitoring + bookmarks = 12
|
|
||||||
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
|
|
||||||
{
|
{
|
||||||
kind: "bookmarks",
|
kind: "weather",
|
||||||
width: 6,
|
xOffset: 2,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
needsIntegration: false,
|
||||||
|
options: {
|
||||||
|
location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
|
||||||
|
hasForecast: false,
|
||||||
|
showHumidity: false,
|
||||||
|
showCurrentWindSpeed: false,
|
||||||
|
showCity: true,
|
||||||
|
animateIcons: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "clock",
|
||||||
|
xOffset: 4,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 2,
|
||||||
|
needsIntegration: false,
|
||||||
|
options: { customTitleToggle: false, showDate: false, customTimeFormat: "HH:mm" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "timer",
|
||||||
|
xOffset: 5,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
needsIntegration: false,
|
||||||
|
options: {
|
||||||
|
mode: "pomodoro",
|
||||||
|
focusMinutes: 25,
|
||||||
|
shortBreakMinutes: 5,
|
||||||
|
longBreakMinutes: 15,
|
||||||
|
sessionsBeforeLongBreak: 4,
|
||||||
|
autoStartBreaks: false,
|
||||||
|
autoStartFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "airQuality",
|
||||||
|
xOffset: 5,
|
||||||
|
yOffset: 1,
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
needsIntegration: false,
|
||||||
|
options: {
|
||||||
|
location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
|
||||||
|
aqiStandard: "european",
|
||||||
|
showUv: false,
|
||||||
|
showPollutants: false,
|
||||||
|
showPollen: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: "downloads", xOffset: 7, yOffset: 0, width: 5, height: 2, needsIntegration: true },
|
||||||
|
|
||||||
|
// Homarr workspace
|
||||||
|
{
|
||||||
|
kind: "notebook",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 2,
|
||||||
|
width: 5,
|
||||||
height: 3,
|
height: 3,
|
||||||
needsIntegration: false,
|
needsIntegration: false,
|
||||||
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
|
options: {
|
||||||
|
showToolbar: false,
|
||||||
|
allowReadOnlyCheck: true,
|
||||||
|
content: `<p style="text-align: center"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg" width="28%"></p><h2 style="text-align: center">Quick runbook</h2><p style="text-align: center">Keep today close: signals first, controls beside them, distractions out.</p><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Backups verified</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Review active alerts</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Connect your everyday services</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Tune this board to your routine</p></div></li></ul><p style="text-align: center"><em>Make it yours. Keep it useful.</em></p>`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
|
{ kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
|
||||||
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
|
{ kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
|
||||||
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
|
|
||||||
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
|
// Operations center
|
||||||
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
|
{ kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
||||||
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
|
{ kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
||||||
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
|
{ kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
||||||
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
|
{ kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
||||||
// Row 5: requestList + requestStats + indexerManager = 12
|
|
||||||
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
|
|
||||||
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
|
|
||||||
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
|
|
||||||
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
|
|
||||||
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
|
|
||||||
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
|
|
||||||
{
|
{
|
||||||
kind: "rssFeed",
|
kind: "rssFeed",
|
||||||
width: 6,
|
xOffset: 0,
|
||||||
|
yOffset: 9,
|
||||||
|
width: 3,
|
||||||
height: 2,
|
height: 2,
|
||||||
needsIntegration: false,
|
needsIntegration: false,
|
||||||
options: {
|
options: {
|
||||||
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
||||||
maximumAmountPosts: 20,
|
maximumAmountPosts: 12,
|
||||||
textLinesClamp: 2,
|
textLinesClamp: 2,
|
||||||
hideDescription: false,
|
hideDescription: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
|
{ kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true },
|
||||||
|
|
||||||
|
// Infrastructure and activity
|
||||||
|
{ kind: "dockerContainers", xOffset: 0, yOffset: 11, width: 4, height: 3, needsIntegration: false },
|
||||||
|
{ kind: "mediaReleases", xOffset: 4, yOffset: 11, width: 4, height: 3, needsIntegration: true },
|
||||||
|
|
||||||
|
// Community Workshop
|
||||||
|
{
|
||||||
|
kind: "customApi",
|
||||||
|
xOffset: 8,
|
||||||
|
yOffset: 11,
|
||||||
|
width: 4,
|
||||||
|
height: 3,
|
||||||
|
needsIntegration: false,
|
||||||
|
options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Network and health
|
||||||
|
{
|
||||||
|
kind: "healthMonitoring",
|
||||||
|
xOffset: 3,
|
||||||
|
yOffset: 5,
|
||||||
|
width: 6,
|
||||||
|
height: 2,
|
||||||
|
needsIntegration: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "dnsHoleSummary",
|
||||||
|
xOffset: 3,
|
||||||
|
yOffset: 7,
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
needsIntegration: true,
|
||||||
|
options: { layout: "grid", usePiHoleColors: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "beszelSystemStats",
|
||||||
|
xOffset: 6,
|
||||||
|
yOffset: 7,
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
needsIntegration: true,
|
||||||
|
},
|
||||||
|
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
|
||||||
|
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
|
||||||
|
|
||||||
|
// Right app rail
|
||||||
...appIds.map(
|
...appIds.map(
|
||||||
(appId): DemoWidget => ({
|
(appId, index): DemoWidget => ({
|
||||||
kind: "app",
|
kind: "app",
|
||||||
width: 2,
|
section: "right",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: index,
|
||||||
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
needsIntegration: false,
|
needsIntegration: false,
|
||||||
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
|
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
// Row 9: beszelSystemGrid + beszelAlerts = 12
|
|
||||||
{ kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
|
|
||||||
{ kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
|
|
||||||
// Row 10: beszelSystemTable + beszelSystemStats = 12
|
|
||||||
{ kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
|
|
||||||
{ kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
|
|
||||||
// Row 11: notebook + dockerContainers + weather = 12
|
|
||||||
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
|
|
||||||
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
|
||||||
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
|
||||||
// Row 12: assistant
|
|
||||||
{ kind: "assistant", width: 3, height: 3, needsIntegration: false },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const seedDemoUserAsync = async (db: Database) => {
|
const seedDemoUserAsync = async (db: Database) => {
|
||||||
@@ -710,44 +807,100 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customWidgetDefinitionId = createId();
|
||||||
|
const customWidgetDefinition = customWidgetDefinitionSchema.parse({
|
||||||
|
$schema: "homarr-custom-widget-v2",
|
||||||
|
name: "Community Workshop",
|
||||||
|
description: "Live Custom Widget and Custom CSS submissions shared by the Homarr community.",
|
||||||
|
sources: {
|
||||||
|
default: {
|
||||||
|
name: "Homarr Workshop",
|
||||||
|
baseUrl: "https://v2.preview.homarr.dev",
|
||||||
|
networkScope: "public",
|
||||||
|
auth: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
workshop: {
|
||||||
|
path: "/api/collections/workshop_listings/records",
|
||||||
|
query: { perPage: 50 },
|
||||||
|
cacheSeconds: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
template: `<Stack p="md" gap="xs" h="100%">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Stack gap={0}><Text size="xs" c="indigo" fw={700}>COMMUNITY WORKSHOP</Text><Title order={3}>Made by Homarr users</Title></Stack>
|
||||||
|
<Badge color="indigo" variant="light">{data.workshop?.totalItems ?? "—"} shared</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">Build Custom Widgets or dashboard CSS, then share it with the community.</Text>
|
||||||
|
{status.workshop?.loading ? <Stack gap="xs"><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /></Stack> : status.workshop?.error ? <Stack gap="xs"><Alert color="red" title="The Workshop could not be loaded">{status.workshop.error}</Alert><RefreshButton /></Stack> : (data.workshop?.items ?? []).length === 0 ? <Stack gap="xs"><Alert color="gray" title="Nothing shared yet">The first community submission could be yours.</Alert><RefreshButton /></Stack> : <Stack gap={4} style={{ flex: 1 }}>{(data.workshop?.items ?? []).slice(0, 4).map(submission => <Group key={submission.id} justify="space-between" wrap="nowrap"><Text size="sm" fw={600} lineClamp={1}>{submission.title}</Text><Group gap="xs" wrap="nowrap"><Text size="xs" c="dimmed">@{submission.authorName ?? "community"}</Text><Badge size="xs" color="gray" variant="light">{submission.upvotes ?? 0} ↑</Badge></Group></Group>)}{data.workshop?.totalItems > 4 ? <Text size="xs" c="dimmed">and {data.workshop.totalItems - 4} more in the Workshop</Text> : null}</Stack>}
|
||||||
|
<Anchor href="https://homarr.dev/docs/getting-started" target="_blank" rel="noreferrer" bg="indigo" c="white" fw={700} px="md" py="xs" radius="md" underline="never">Install Homarr now →</Anchor>
|
||||||
|
</Stack>`,
|
||||||
|
});
|
||||||
|
await db.insert(customWidgetDefinitions).values({
|
||||||
|
id: customWidgetDefinitionId,
|
||||||
|
name: customWidgetDefinition.name,
|
||||||
|
description: customWidgetDefinition.description ?? null,
|
||||||
|
iconUrl: null,
|
||||||
|
sources: SuperJSON.stringify(customWidgetDefinition.sources),
|
||||||
|
requests: SuperJSON.stringify(customWidgetDefinition.requests),
|
||||||
|
options: SuperJSON.stringify(customWidgetDefinition.options),
|
||||||
|
template: customWidgetDefinition.template,
|
||||||
|
enabled: true,
|
||||||
|
creatorId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
await db.insert(boards).values({
|
await db.insert(boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
name: "default",
|
name: "default",
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
|
pageTitle: "Homarr demo",
|
||||||
|
backgroundImageUrl: "/images/demo-dashboard-background.svg",
|
||||||
|
primaryColor: "#748FFC",
|
||||||
|
secondaryColor: "#3BC9DB",
|
||||||
|
opacity: 90,
|
||||||
|
itemRadius: "xl",
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionId = createId();
|
const mainSectionId = createId();
|
||||||
await db.insert(sections).values({
|
await db.insert(sections).values({
|
||||||
id: sectionId,
|
id: mainSectionId,
|
||||||
kind: "empty",
|
kind: "empty",
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
boardId,
|
boardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rightSectionId = createId();
|
||||||
|
await db.insert(sections).values({
|
||||||
|
id: rightSectionId,
|
||||||
|
kind: "empty",
|
||||||
|
xOffset: 1,
|
||||||
|
yOffset: 0,
|
||||||
|
boardId,
|
||||||
|
});
|
||||||
|
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
await db.insert(layouts).values({
|
await db.insert(layouts).values({
|
||||||
id: layoutId,
|
id: layoutId,
|
||||||
name: "Base",
|
name: "Base",
|
||||||
columnCount: 12,
|
columnCount: 13,
|
||||||
|
rightGutterColumnCount: 1,
|
||||||
breakpoint: 768,
|
breakpoint: 768,
|
||||||
role: "base",
|
role: "base",
|
||||||
boardId,
|
boardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const demoWidgets = buildDemoWidgets(appIds);
|
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
|
||||||
let xOffset = 0;
|
|
||||||
let yOffset = 0;
|
|
||||||
let rowMaxHeight = 0;
|
|
||||||
for (const widget of demoWidgets) {
|
for (const widget of demoWidgets) {
|
||||||
if (xOffset + widget.width > 12) {
|
let sectionId = mainSectionId;
|
||||||
xOffset = 0;
|
if (widget.section === "right") {
|
||||||
yOffset += rowMaxHeight;
|
sectionId = rightSectionId;
|
||||||
rowMaxHeight = 0;
|
|
||||||
}
|
}
|
||||||
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
|
|
||||||
const itemId = createId();
|
const itemId = createId();
|
||||||
await db.insert(items).values({
|
await db.insert(items).values({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
@@ -759,8 +912,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
itemId,
|
itemId,
|
||||||
sectionId,
|
sectionId,
|
||||||
layoutId,
|
layoutId,
|
||||||
xOffset,
|
xOffset: widget.xOffset,
|
||||||
yOffset,
|
yOffset: widget.yOffset,
|
||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
});
|
});
|
||||||
@@ -770,7 +923,6 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
integrationId,
|
integrationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
xOffset += widget.width;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||||
|
|||||||
@@ -45,6 +45,169 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
|
|||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SQLite migrations seed the redesigned demo dashboard", async () => {
|
||||||
|
const previousDemoMode = process.env.DEMO_MODE;
|
||||||
|
process.env.DEMO_MODE = "true";
|
||||||
|
const connection = new BetterSqlite3(":memory:");
|
||||||
|
const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") });
|
||||||
|
await seedDataAsync(database as unknown as Database);
|
||||||
|
|
||||||
|
const demoUser = await database.query.users.findFirst({
|
||||||
|
where: (table, { eq }) => eq(table.name, "demo"),
|
||||||
|
});
|
||||||
|
if (!demoUser?.homeBoardId) throw new Error("Demo user or home board was not seeded");
|
||||||
|
|
||||||
|
const board = await database.query.boards.findFirst({
|
||||||
|
where: (table, { eq }) => eq(table.id, demoUser.homeBoardId ?? ""),
|
||||||
|
with: {
|
||||||
|
layouts: true,
|
||||||
|
sections: { with: { collapseStates: true, layouts: true } },
|
||||||
|
items: { with: { layouts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!board) throw new Error("Demo board was not seeded");
|
||||||
|
|
||||||
|
expect(board).toMatchObject({
|
||||||
|
pageTitle: "Homarr demo",
|
||||||
|
backgroundImageUrl: "/images/demo-dashboard-background.svg",
|
||||||
|
});
|
||||||
|
expect(board.layouts).toHaveLength(2);
|
||||||
|
expect(board.layouts).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ columnCount: 13, rightGutterColumnCount: 1, role: "base" }),
|
||||||
|
expect.objectContaining({ columnCount: 3, rightGutterColumnCount: 0, role: "mobile" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||||
|
if (!baseLayout) throw new Error("Demo base layout was not seeded");
|
||||||
|
|
||||||
|
const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0);
|
||||||
|
const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1);
|
||||||
|
if (!mainSection || !rightSection) throw new Error("Demo board sections were not seeded");
|
||||||
|
expect(board.sections.some((section) => section.kind === "container")).toBe(false);
|
||||||
|
|
||||||
|
const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind);
|
||||||
|
const expectItemLayout = (
|
||||||
|
kind: string,
|
||||||
|
xOffset: number,
|
||||||
|
yOffset: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
sectionId = mainSection.id,
|
||||||
|
) => {
|
||||||
|
const layout = itemByKind(kind)?.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
|
||||||
|
expect(layout).toMatchObject({ sectionId, xOffset, yOffset, width, height });
|
||||||
|
};
|
||||||
|
expectItemLayout("calendar", 0, 0, 2, 2);
|
||||||
|
expectItemLayout("weather", 2, 0, 2, 2);
|
||||||
|
expectItemLayout("clock", 4, 0, 1, 2);
|
||||||
|
expectItemLayout("timer", 5, 0, 2, 1);
|
||||||
|
expectItemLayout("airQuality", 5, 1, 2, 1);
|
||||||
|
expectItemLayout("downloads", 7, 0, 5, 2);
|
||||||
|
expectItemLayout("notebook", 0, 2, 5, 3);
|
||||||
|
expectItemLayout("beszelSystemGrid", 5, 2, 4, 3);
|
||||||
|
expectItemLayout("assistant", 9, 2, 3, 3);
|
||||||
|
expectItemLayout("mediaRequests-requestList", 0, 5, 3, 2);
|
||||||
|
expectItemLayout("mediaMissing", 9, 5, 3, 2);
|
||||||
|
expectItemLayout("mediaServer", 0, 7, 3, 2);
|
||||||
|
expectItemLayout("mediaRequests-requestStats", 9, 7, 3, 2);
|
||||||
|
expectItemLayout("rssFeed", 0, 9, 3, 2);
|
||||||
|
expectItemLayout("indexerManager", 9, 9, 3, 2);
|
||||||
|
expectItemLayout("dockerContainers", 0, 11, 4, 3);
|
||||||
|
expectItemLayout("mediaReleases", 4, 11, 4, 3);
|
||||||
|
expectItemLayout("customApi", 8, 11, 4, 3);
|
||||||
|
|
||||||
|
expectItemLayout("healthMonitoring", 3, 5, 6, 2);
|
||||||
|
expectItemLayout("dnsHoleSummary", 3, 7, 3, 2);
|
||||||
|
expectItemLayout("beszelSystemStats", 6, 7, 3, 2);
|
||||||
|
expectItemLayout("notifications", 3, 9, 2, 2);
|
||||||
|
expectItemLayout("beszelAlerts", 5, 9, 4, 2);
|
||||||
|
|
||||||
|
for (const kind of ["weather", "airQuality"]) {
|
||||||
|
const item = itemByKind(kind);
|
||||||
|
if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
|
||||||
|
expect(SuperJSON.parse<{ location: { name: string } }>(item.options).location.name).toBe("Paris");
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectFilledGrid = (
|
||||||
|
placements: { xOffset: number; yOffset: number; width: number; height: number }[],
|
||||||
|
columnCount: number,
|
||||||
|
) => {
|
||||||
|
const rowCount = Math.max(...placements.map((placement) => placement.yOffset + placement.height));
|
||||||
|
const cells = Array.from({ length: rowCount }, () => Array<number>(columnCount).fill(0));
|
||||||
|
for (const placement of placements) {
|
||||||
|
for (let y = placement.yOffset; y < placement.yOffset + placement.height; y += 1) {
|
||||||
|
for (let x = placement.xOffset; x < placement.xOffset + placement.width; x += 1) {
|
||||||
|
const row = cells[y];
|
||||||
|
if (!row) throw new Error("Demo placement exceeds the grid height");
|
||||||
|
row[x] = (row[x] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(cells.every((row) => row.length === columnCount && row.every((cell) => cell === 1))).toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainItemLayouts = board.items.flatMap((item) =>
|
||||||
|
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id),
|
||||||
|
);
|
||||||
|
expectFilledGrid(mainItemLayouts, 12);
|
||||||
|
|
||||||
|
const rightRailItems = board.items.filter((item) =>
|
||||||
|
item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
|
||||||
|
);
|
||||||
|
expect(rightRailItems).toHaveLength(12);
|
||||||
|
const rightRailLayouts = rightRailItems.flatMap((item) =>
|
||||||
|
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
rightRailItems.every((item) => {
|
||||||
|
const layout = item.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
|
||||||
|
return item.kind === "app" && layout?.width === 1 && layout.height === 1;
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
rightRailLayouts
|
||||||
|
.toSorted((first, second) => first.yOffset - second.yOffset)
|
||||||
|
.map(({ sectionId, xOffset, yOffset, width, height }) => ({
|
||||||
|
sectionId,
|
||||||
|
xOffset,
|
||||||
|
yOffset,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})),
|
||||||
|
).toEqual(
|
||||||
|
Array.from({ length: 12 }, (_, yOffset) => ({
|
||||||
|
sectionId: rightSection.id,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
expectFilledGrid(rightRailLayouts, 1);
|
||||||
|
|
||||||
|
const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({
|
||||||
|
where: (table, { eq }) => eq(table.creatorId, demoUser.id),
|
||||||
|
});
|
||||||
|
if (!workshopDefinition) throw new Error("Demo Workshop definition was not seeded");
|
||||||
|
expect(workshopDefinition.name).toBe("Community Workshop");
|
||||||
|
expect(SuperJSON.parse<Record<string, { baseUrl: string }>>(workshopDefinition.sources).default?.baseUrl).toBe(
|
||||||
|
"https://v2.preview.homarr.dev",
|
||||||
|
);
|
||||||
|
|
||||||
|
const workshopItem = itemByKind("customApi");
|
||||||
|
if (!workshopItem) throw new Error("Demo Workshop item was not seeded");
|
||||||
|
expect(SuperJSON.parse<{ definitionId: string }>(workshopItem.options).definitionId).toBe(workshopDefinition.id);
|
||||||
|
} finally {
|
||||||
|
connection.close();
|
||||||
|
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
||||||
|
else process.env.DEMO_MODE = previousDemoMode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Custom Widget v2 migration preserves v1 data and board references", () => {
|
test("Custom Widget v2 migration preserves v1 data and board references", () => {
|
||||||
const connection = new BetterSqlite3(":memory:");
|
const connection = new BetterSqlite3(":memory:");
|
||||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export interface DefaultWidgetConfig {
|
|||||||
|
|
||||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||||
{ kind: "clock", width: 2, height: 1 },
|
{ kind: "clock", width: 2, height: 1 },
|
||||||
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
|
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
|
||||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
.inspectorContent {
|
.inspectorContent {
|
||||||
--modal-content-flex: 0 0 100%;
|
--modal-content-flex: 0 0 100%;
|
||||||
--modal-content-max-height: min(88dvh, 52rem);
|
--modal-content-max-height: min(88dvh, 52rem);
|
||||||
|
--modal-size: 100%;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +25,13 @@
|
|||||||
--modal-content-flex: 0 0 min(34rem, 46vw);
|
--modal-content-flex: 0 0 min(34rem, 46vw);
|
||||||
--modal-content-height: 100dvh;
|
--modal-content-height: 100dvh;
|
||||||
--modal-content-max-height: 100dvh;
|
--modal-content-max-height: 100dvh;
|
||||||
|
--modal-size: min(34rem, 46vw);
|
||||||
|
|
||||||
width: min(34rem, 46vw);
|
width: min(34rem, 46vw);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inspectorInner:has([data-widget-inspector-side="left"]) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
|
|||||||
| "centered"
|
| "centered"
|
||||||
| "keepMounted"
|
| "keepMounted"
|
||||||
| "withCloseButton"
|
| "withCloseButton"
|
||||||
|
| "withOverlay"
|
||||||
| "zIndex"
|
| "zIndex"
|
||||||
| "scrollAreaComponent"
|
| "scrollAreaComponent"
|
||||||
| "yOffset"
|
| "yOffset"
|
||||||
|
|||||||
@@ -2192,8 +2192,51 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"label": "Layout",
|
"label": "Layout",
|
||||||
"option": {
|
"option": {
|
||||||
|
"adaptive": {
|
||||||
|
"label": "Adaptive"
|
||||||
|
},
|
||||||
"gridHorizontal": {
|
"gridHorizontal": {
|
||||||
"label": "Grid horizontal"
|
"label": "Compact grid"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"label": "Icons"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"label": "Appearance",
|
||||||
|
"option": {
|
||||||
|
"soft": {
|
||||||
|
"label": "Soft"
|
||||||
|
},
|
||||||
|
"filled": {
|
||||||
|
"label": "Filled"
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"label": "Outline"
|
||||||
|
},
|
||||||
|
"plain": {
|
||||||
|
"label": "Plain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"label": "Card spacing",
|
||||||
|
"option": {
|
||||||
|
"xs": {
|
||||||
|
"label": "Extra small"
|
||||||
|
},
|
||||||
|
"sm": {
|
||||||
|
"label": "Small"
|
||||||
|
},
|
||||||
|
"md": {
|
||||||
|
"label": "Medium"
|
||||||
|
},
|
||||||
|
"lg": {
|
||||||
|
"label": "Large"
|
||||||
|
},
|
||||||
|
"xl": {
|
||||||
|
"label": "Extra large"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2210,17 +2253,21 @@
|
|||||||
"label": "Show border"
|
"label": "Show border"
|
||||||
},
|
},
|
||||||
"customUrls": {
|
"customUrls": {
|
||||||
"label": "Custom links",
|
"label": "Custom links"
|
||||||
"description": "Add URLs directly without creating an App first"
|
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "Bookmarks",
|
"label": "Bookmarks",
|
||||||
|
"remove": "Remove bookmark",
|
||||||
"apps": {
|
"apps": {
|
||||||
"label": "Apps",
|
"label": "Add bookmarks",
|
||||||
"placeholder": "Select one or more apps"
|
"description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.",
|
||||||
|
"placeholder": "Paste URLs or search Apps",
|
||||||
|
"addUrl": "Add this URL",
|
||||||
|
"hint": "Press Enter to add a URL. Paste comma-separated or one-per-line URLs to add several."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"empty": "Add a URL or App in the widget settings"
|
||||||
},
|
},
|
||||||
"archiveTeamWarrior": {
|
"archiveTeamWarrior": {
|
||||||
"name": "ArchiveTeam Warrior",
|
"name": "ArchiveTeam Warrior",
|
||||||
@@ -3012,12 +3059,10 @@
|
|||||||
"label": "Animate weather icons"
|
"label": "Animate weather icons"
|
||||||
},
|
},
|
||||||
"showHumidity": {
|
"showHumidity": {
|
||||||
"label": "Show humidity",
|
"label": "Show humidity"
|
||||||
"description": "Display humidity in current conditions, hourly details and daily forecasts"
|
|
||||||
},
|
},
|
||||||
"showCurrentWindSpeed": {
|
"showCurrentWindSpeed": {
|
||||||
"label": "Show current wind speed",
|
"label": "Show current wind speed"
|
||||||
"description": "Only on current weather"
|
|
||||||
},
|
},
|
||||||
"useImperialSpeed": {
|
"useImperialSpeed": {
|
||||||
"label": "Use mph for windspeed"
|
"label": "Use mph for windspeed"
|
||||||
@@ -5130,6 +5175,25 @@
|
|||||||
"addContent": "Add board content",
|
"addContent": "Add board content",
|
||||||
"settings": "Board settings",
|
"settings": "Board settings",
|
||||||
"switch": "Switch board",
|
"switch": "Switch board",
|
||||||
|
"switcher": {
|
||||||
|
"title": "Switch board",
|
||||||
|
"search": {
|
||||||
|
"empty": "No boards match \"{search}\"."
|
||||||
|
},
|
||||||
|
"results": "Available boards",
|
||||||
|
"preview": "Preview of {name} with {count} items",
|
||||||
|
"currentHidden": "Current board: {name}",
|
||||||
|
"keyboard": {
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"open": "Open",
|
||||||
|
"instructions": "Type to filter boards. Use the arrow keys to move through results and Enter to open a board."
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No other boards",
|
||||||
|
"description": "There are no other boards you can access."
|
||||||
|
},
|
||||||
|
"error": "Boards could not be loaded."
|
||||||
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"availability": {
|
"availability": {
|
||||||
"checking": "Checking availability…",
|
"checking": "Checking availability…",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const theme = createTheme({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
|
xxs: rem(11),
|
||||||
xs: rem(12),
|
xs: rem(12),
|
||||||
sm: rem(14),
|
sm: rem(14),
|
||||||
md: rem(16),
|
md: rem(16),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
|||||||
boardId?: string;
|
boardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short description why as and unknown convertions are used below:
|
* Short description why as and unknown convertions are used below:
|
||||||
|
|||||||
@@ -96,6 +96,28 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const migrateItems = useCallback(
|
||||||
|
(items: TItem[], optionsPatch: Record<string, unknown>) => {
|
||||||
|
const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const);
|
||||||
|
setTempMap((previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
for (const [value, item] of migratedEntries) next.set(value, item);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
form.setFieldValue("options", (currentOptions) => {
|
||||||
|
const currentItems = Array.isArray(currentOptions[property])
|
||||||
|
? (currentOptions[property] as TOptionValue[])
|
||||||
|
: options.defaultValue;
|
||||||
|
const nextItems = [...currentItems];
|
||||||
|
for (const [value] of migratedEntries) {
|
||||||
|
if (!nextItems.includes(value)) nextItems.push(value);
|
||||||
|
}
|
||||||
|
return { ...currentOptions, ...optionsPatch, [property]: nextItems };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[form, options, property],
|
||||||
|
);
|
||||||
|
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback(
|
||||||
(value: TOptionValue) => {
|
(value: TOptionValue) => {
|
||||||
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
||||||
@@ -112,7 +134,13 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
return (
|
return (
|
||||||
<Fieldset legend={t("label")}>
|
<Fieldset legend={t("label")}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
|
<options.addButton
|
||||||
|
addItem={addItem}
|
||||||
|
migrateItems={migrateItems}
|
||||||
|
removeItem={removeItem}
|
||||||
|
values={values}
|
||||||
|
initialOptions={initialOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -153,6 +181,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
index={index}
|
index={index}
|
||||||
item={item}
|
item={item}
|
||||||
removeItem={() => removeItem(value)}
|
removeItem={() => removeItem(value)}
|
||||||
|
removeLabel={t("remove")}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -177,6 +206,7 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
|
|||||||
item: TItem;
|
item: TItem;
|
||||||
index: number;
|
index: number;
|
||||||
removeItem: () => void;
|
removeItem: () => void;
|
||||||
|
removeLabel: string;
|
||||||
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +215,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
|||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
removeItem,
|
removeItem,
|
||||||
|
removeLabel,
|
||||||
options,
|
options,
|
||||||
}: ItemProps<TItem, TOptionValue>) => {
|
}: ItemProps<TItem, TOptionValue>) => {
|
||||||
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||||
@@ -220,6 +251,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
|||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
removeItem={removeItem}
|
removeItem={removeItem}
|
||||||
|
removeLabel={removeLabel}
|
||||||
rootAttributes={attributes}
|
rootAttributes={attributes}
|
||||||
handle={
|
handle={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
|
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
|
||||||
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
|
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
|
||||||
{status.broadcastMessage}
|
{status.broadcastMessage}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -143,7 +143,13 @@ const ArchiveTeamWarriorWidgetContent = ({
|
|||||||
{status.items.map((item) => {
|
{status.items.map((item) => {
|
||||||
const itemStatusKey = getStatusKey(item.status);
|
const itemStatusKey = getStatusKey(item.status);
|
||||||
return (
|
return (
|
||||||
<Card key={item.id} withBorder p="xs">
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
withBorder
|
||||||
|
p="xs"
|
||||||
|
bg="transparent"
|
||||||
|
style={{ borderColor: neutralSurfaceBorder }}
|
||||||
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Stack gap={0} miw={0}>
|
<Stack gap={0} miw={0}>
|
||||||
<Text size="sm" fw={600} lineClamp={1}>
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
@@ -201,6 +207,9 @@ export const getArchiveCompactLayout = (width: number, height: number, isAdvance
|
|||||||
|
|
||||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||||
|
|
||||||
|
const neutralSurfaceBorder =
|
||||||
|
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||||
|
|
||||||
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
|
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
|
||||||
|
|
||||||
const getStatusKey = (status?: string | null): WarriorStatusKey => {
|
const getStatusKey = (status?: string | null): WarriorStatusKey => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
|
|||||||
return (
|
return (
|
||||||
<Center h="100%" p="md">
|
<Center h="100%" p="md">
|
||||||
<Stack align="center" gap="xs" ta="center">
|
<Stack align="center" gap="xs" ta="center">
|
||||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
<ThemeIcon variant="light" radius="xl" size="lg">
|
||||||
<IconRobot size="var(--mantine-font-size-xl)" />
|
<IconRobot size="var(--mantine-font-size-xl)" />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
gap: clamp(2px, 1.5cqi, 4px);
|
gap: clamp(2px, 1.5cqi, 4px);
|
||||||
padding: clamp(4px, 2cqi, 8px);
|
padding: clamp(4px, 2cqi, 8px);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
background-color: transparent;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
gap: clamp(2px, 1.5cqi, 5px);
|
gap: clamp(2px, 1.5cqi, 5px);
|
||||||
padding: clamp(4px, 2.5cqi, 10px);
|
padding: clamp(4px, 2.5cqi, 10px);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
background-color: transparent;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
<Progress
|
||||||
|
value={system.disk}
|
||||||
|
color={thresholdColor(system.disk)}
|
||||||
|
size={trackSize}
|
||||||
|
/>
|
||||||
<Group
|
<Group
|
||||||
gap={dotGap}
|
gap={dotGap}
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
@@ -101,7 +105,12 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
|||||||
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
||||||
{formatPercent(value)}
|
{formatPercent(value)}
|
||||||
</Text>
|
</Text>
|
||||||
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
|
<Progress
|
||||||
|
value={value}
|
||||||
|
color={thresholdColor(value)}
|
||||||
|
size="xs"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
@@ -111,7 +120,11 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
|||||||
) : (
|
) : (
|
||||||
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
|
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
|
||||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
<Progress
|
||||||
|
value={system.disk}
|
||||||
|
color={thresholdColor(system.disk)}
|
||||||
|
size={trackSize}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,79 +1,198 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MultiSelectProps } from "@mantine/core";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Group, Loader, MultiSelect } from "@mantine/core";
|
import {
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
Avatar,
|
||||||
|
Combobox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
PillsInput,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useCombobox,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SortableItemListInput } from "../options";
|
import type { SortableItemListInput } from "../options";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||||
|
import type { BookmarkItem } from "./bookmark-item";
|
||||||
|
|
||||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||||
|
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
|
||||||
|
|
||||||
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
|
const createOptionValue = "$create-url";
|
||||||
|
|
||||||
|
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
|
||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
migrateItems,
|
||||||
values,
|
values,
|
||||||
|
initialOptions,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n("widget.bookmarks.option.items.apps");
|
const t = useI18n("widget.bookmarks.option.items.apps");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tSelect = useI18n("app.action.select");
|
const tSelect = useI18n("app.action.select");
|
||||||
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
||||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
const [search, setSearch] = useState("");
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
|
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||||
|
});
|
||||||
|
const selectedValues = useMemo(() => new Set(values), [values]);
|
||||||
|
const migrationStartedRef = useRef(false);
|
||||||
|
const legacyUrls = useMemo(() => {
|
||||||
|
if (!Array.isArray(initialOptions.customUrls)) return [];
|
||||||
|
return initialOptions.customUrls.filter((value): value is string => typeof value === "string");
|
||||||
|
}, [initialOptions.customUrls]);
|
||||||
|
|
||||||
const handleChange = (nextValues: string[]) => {
|
useEffect(() => {
|
||||||
const nextIds = new Set(nextValues);
|
if (migrationStartedRef.current || legacyUrls.length === 0) return;
|
||||||
for (const value of values) {
|
|
||||||
if (!nextIds.has(value)) removeItem(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIds = new Set(values);
|
migrationStartedRef.current = true;
|
||||||
for (const value of nextValues) {
|
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
|
||||||
if (currentIds.has(value)) continue;
|
migrateItems(legacyItems, { customUrls: [] });
|
||||||
const app = appsById.get(value);
|
}, [legacyUrls, migrateItems]);
|
||||||
if (app) addItem(app);
|
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
const filteredApps = apps.filter((app) => {
|
||||||
|
if (selectedValues.has(app.id)) return false;
|
||||||
|
if (normalizedSearch.length === 0) return true;
|
||||||
|
|
||||||
|
return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
const pendingUrl = createDirectBookmark(search);
|
||||||
|
|
||||||
|
const addUrls = (input: string) => {
|
||||||
|
const nextValues = new Set(values);
|
||||||
|
for (const value of splitBookmarkUrls(input)) {
|
||||||
|
const item = createDirectBookmark(value);
|
||||||
|
if (!item || nextValues.has(item.id)) continue;
|
||||||
|
addItem(item);
|
||||||
|
nextValues.add(item.id);
|
||||||
}
|
}
|
||||||
|
setSearch("");
|
||||||
|
combobox.closeDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOptionSubmit = (value: string) => {
|
||||||
|
if (value === createOptionValue) {
|
||||||
|
addUrls(search);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = apps.find((candidate) => candidate.id === value);
|
||||||
|
if (app) addItem(app);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = filteredApps.map((app) => (
|
||||||
|
<Combobox.Option value={app.id} key={app.id}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Avatar src={app.iconUrl} name={app.name} color="gray" size={28} radius="sm">
|
||||||
|
<IconLink size={16} />
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0} flex={1}>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{app.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{app.href}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconPlus size={16} aria-hidden />
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect
|
<Combobox
|
||||||
label={t("label")}
|
store={combobox}
|
||||||
placeholder={t("placeholder")}
|
onOptionSubmit={handleOptionSubmit}
|
||||||
value={values}
|
position="bottom-start"
|
||||||
onChange={handleChange}
|
middlewares={{ flip: true, shift: true }}
|
||||||
searchable
|
styles={{
|
||||||
clearable
|
dropdown: { overflow: "hidden" },
|
||||||
clearSearchOnChange
|
option: { borderRadius: "var(--mantine-radius-sm)" },
|
||||||
hidePickedOptions
|
}}
|
||||||
disabled={isPending || Boolean(error)}
|
>
|
||||||
error={error ? tCommon("error") : undefined}
|
<Combobox.DropdownTarget>
|
||||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
<PillsInput
|
||||||
nothingFoundMessage={tSelect("notFound")}
|
label={t("label")}
|
||||||
renderOption={renderAppOption}
|
description={t("description")}
|
||||||
data={apps.map((app) => ({
|
error={error ? tCommon("error") : undefined}
|
||||||
value: app.id,
|
onClick={() => combobox.openDropdown()}
|
||||||
label: app.name,
|
leftSection={
|
||||||
iconUrl: app.iconUrl,
|
<ThemeIcon variant="light" size="sm" radius="xl">
|
||||||
}))}
|
<IconLink size={14} />
|
||||||
/>
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
||||||
|
styles={{ input: { minHeight: 44 } }}
|
||||||
|
>
|
||||||
|
<Combobox.EventsTarget>
|
||||||
|
<PillsInput.Field
|
||||||
|
value={search}
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
onBlur={() => combobox.closeDropdown()}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
combobox.openDropdown();
|
||||||
|
combobox.updateSelectedOptionIndex();
|
||||||
|
}}
|
||||||
|
onPaste={(event) => {
|
||||||
|
const pastedValue = event.clipboardData.getData("text");
|
||||||
|
if (splitBookmarkUrls(pastedValue).length < 2) return;
|
||||||
|
event.preventDefault();
|
||||||
|
addUrls(pastedValue);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
if (!pendingUrl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
addUrls(search);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Combobox.EventsTarget>
|
||||||
|
</PillsInput>
|
||||||
|
</Combobox.DropdownTarget>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<ScrollArea.Autosize mah={280} type="auto">
|
||||||
|
<Combobox.Options>
|
||||||
|
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
|
||||||
|
<Combobox.Option value={createOptionValue}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Avatar src={getBookmarkFaviconUrl(pendingUrl.href)} size={28} radius="sm" color="gray">
|
||||||
|
<IconLink size={16} />
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0} flex={1}>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{t("addUrl")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{pendingUrl.href}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconCheck size={16} aria-hidden />
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
) : null}
|
||||||
|
{options}
|
||||||
|
{!pendingUrl && options.length === 0 ? <Combobox.Empty>{tSelect("notFound")}</Combobox.Empty> : null}
|
||||||
|
</Combobox.Options>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
<Combobox.Footer>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("hint")}
|
||||||
|
</Text>
|
||||||
|
</Combobox.Footer>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconProps = {
|
|
||||||
stroke: 1.5,
|
|
||||||
color: "currentColor",
|
|
||||||
opacity: 0.6,
|
|
||||||
size: 18,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
|
|
||||||
<Group flex="1" gap="xs">
|
|
||||||
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
|
|
||||||
<img width={20} height={20} src={option.iconUrl} alt="" />
|
|
||||||
) : null}
|
|
||||||
{option.label}
|
|
||||||
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { getSafeApplicationUrl } from "../common/application-url";
|
||||||
|
|
||||||
|
export const directBookmarkPrefix = "url:";
|
||||||
|
|
||||||
|
export interface BookmarkItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
iconUrl?: string;
|
||||||
|
href: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeBookmarkUrl = (value: string): string | undefined => {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (trimmedValue.length === 0) return undefined;
|
||||||
|
|
||||||
|
const directUrl = getSafeApplicationUrl(trimmedValue);
|
||||||
|
if (directUrl) return directUrl;
|
||||||
|
|
||||||
|
if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) {
|
||||||
|
return getSafeApplicationUrl(`https://${trimmedValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`;
|
||||||
|
|
||||||
|
export const getDirectBookmarkUrl = (value: string): string | undefined => {
|
||||||
|
if (!value.startsWith(directBookmarkPrefix)) return undefined;
|
||||||
|
return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBookmarkFaviconUrl = (href: string | null): string | undefined => {
|
||||||
|
const safeUrl = getSafeApplicationUrl(href);
|
||||||
|
if (!safeUrl) return undefined;
|
||||||
|
|
||||||
|
const url = new URL(safeUrl);
|
||||||
|
return `${url.origin}/favicon.ico`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDirectBookmark = (url: string): BookmarkItem | undefined => {
|
||||||
|
const href = normalizeBookmarkUrl(url);
|
||||||
|
if (!href) return undefined;
|
||||||
|
|
||||||
|
const parsedUrl = new URL(href);
|
||||||
|
return {
|
||||||
|
id: getDirectBookmarkValue(href),
|
||||||
|
name: parsedUrl.hostname,
|
||||||
|
description: null,
|
||||||
|
href,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const splitBookmarkUrls = (value: string) =>
|
||||||
|
value.split(/\r?\n+/).flatMap((line) => {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.length === 0) return [];
|
||||||
|
|
||||||
|
const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim());
|
||||||
|
if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) {
|
||||||
|
return commaSeparatedValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [trimmedLine];
|
||||||
|
});
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
.card:hover {
|
|
||||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarkButton:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="light"] .card-grid {
|
|
||||||
background-color: var(--mantine-color-gray-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .card-grid {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover > div > div.bookmarkIcon {
|
|
||||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarkButton:focus-visible .bookmarkIcon {
|
|
||||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useReducedMotion } from "@mantine/hooks";
|
||||||
|
import { IconArrowUpRight, IconBookmark, IconLink } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
import type { BookmarkItem } from "./bookmark-item";
|
||||||
|
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||||
|
import type { BookmarkOrientation } from "./layout";
|
||||||
|
|
||||||
|
type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"];
|
||||||
|
|
||||||
|
export default function BookmarksWidget({
|
||||||
|
options,
|
||||||
|
itemId,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
displayMode,
|
||||||
|
}: WidgetComponentProps<"bookmarks">) {
|
||||||
|
const t = useI18n("widget.bookmarks");
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const advanced = displayMode === "advanced";
|
||||||
|
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
|
||||||
|
const apps =
|
||||||
|
getUsableWidgetQueryData(
|
||||||
|
clientApi.app.byIds.useQuery(appIds, {
|
||||||
|
select(selectedApps) {
|
||||||
|
return selectedApps;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
) ?? [];
|
||||||
|
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||||
|
const configuredItems = options.items.flatMap((value) => {
|
||||||
|
const directUrl = getDirectBookmarkUrl(value);
|
||||||
|
if (directUrl) return createDirectBookmark(directUrl) ?? [];
|
||||||
|
return appsById.get(value) ?? [];
|
||||||
|
});
|
||||||
|
const configuredHrefs = new Set(configuredItems.map((item) => item.href));
|
||||||
|
const legacyItems = options.customUrls.flatMap((url) => {
|
||||||
|
const item = createDirectBookmark(url);
|
||||||
|
if (!item || configuredHrefs.has(item.href)) return [];
|
||||||
|
return [item];
|
||||||
|
});
|
||||||
|
const data = [...configuredItems, ...legacyItems];
|
||||||
|
|
||||||
|
useRegisterSpotlightContextResults(
|
||||||
|
`bookmark-${itemId}`,
|
||||||
|
data.flatMap((bookmark) => {
|
||||||
|
const href = getSafeAppHref(bookmark.href);
|
||||||
|
if (!href) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: bookmark.id,
|
||||||
|
name: bookmark.name,
|
||||||
|
icon: bookmark.iconUrl ?? IconLink,
|
||||||
|
interaction() {
|
||||||
|
return {
|
||||||
|
type: "link" as const,
|
||||||
|
href,
|
||||||
|
newTab: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = useMemo(
|
||||||
|
() =>
|
||||||
|
getBookmarkDisplayPlan({
|
||||||
|
advanced,
|
||||||
|
height: options.title.length > 0 ? height - 36 : height,
|
||||||
|
itemCount: data.length,
|
||||||
|
layout: options.layout,
|
||||||
|
width,
|
||||||
|
}),
|
||||||
|
[advanced, data.length, height, options.layout, options.title.length, width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardDisplay = getBookmarkCardDisplay({
|
||||||
|
advanced,
|
||||||
|
hideHostname: options.hideHostname,
|
||||||
|
hideIcon: options.hideIcon,
|
||||||
|
hideTitle: options.hideTitle,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cards = data.map((bookmark) => (
|
||||||
|
<BookmarkCard
|
||||||
|
key={bookmark.id}
|
||||||
|
bookmark={bookmark}
|
||||||
|
advanced={advanced}
|
||||||
|
orientation={cardDisplay.orientation}
|
||||||
|
showHostname={cardDisplay.showHostname}
|
||||||
|
showIcon={cardDisplay.showIcon}
|
||||||
|
showTitle={cardDisplay.showTitle}
|
||||||
|
openNewTab={options.openNewTab}
|
||||||
|
variant={options.variant}
|
||||||
|
withBorder={options.withBorder}
|
||||||
|
radius={board.itemRadius}
|
||||||
|
height={plan.itemHeight}
|
||||||
|
width={plan.horizontalScroll ? plan.itemWidth : undefined}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
|
||||||
|
{options.title.length > 0 ? (
|
||||||
|
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
|
||||||
|
{options.title}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<Center flex={1}>
|
||||||
|
<Stack align="center" gap={6}>
|
||||||
|
<ThemeIcon variant="light" size="lg" radius="xl">
|
||||||
|
<IconBookmark size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t("empty")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<ScrollArea
|
||||||
|
type="hover"
|
||||||
|
scrollbars={plan.horizontalScroll ? "x" : "y"}
|
||||||
|
scrollbarSize={2}
|
||||||
|
scrollHideDelay={500}
|
||||||
|
offsetScrollbars
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
>
|
||||||
|
<Box miw="100%" mih="100%" pb={2}>
|
||||||
|
{plan.horizontalScroll ? (
|
||||||
|
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
|
||||||
|
{cards}
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid cols={plan.columns} spacing={options.spacing} verticalSpacing={options.spacing}>
|
||||||
|
{cards}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookmarkCardProps {
|
||||||
|
advanced: boolean;
|
||||||
|
bookmark: BookmarkItem;
|
||||||
|
height: number;
|
||||||
|
openNewTab: boolean;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
radius: string;
|
||||||
|
showHostname: boolean;
|
||||||
|
showIcon: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
variant: BookmarkVariant;
|
||||||
|
width?: number;
|
||||||
|
withBorder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookmarkCard = ({
|
||||||
|
advanced,
|
||||||
|
bookmark,
|
||||||
|
height,
|
||||||
|
openNewTab,
|
||||||
|
orientation,
|
||||||
|
radius,
|
||||||
|
showHostname,
|
||||||
|
showIcon,
|
||||||
|
showTitle,
|
||||||
|
variant,
|
||||||
|
width,
|
||||||
|
withBorder,
|
||||||
|
}: BookmarkCardProps) => {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const href = getSafeAppHref(bookmark.href);
|
||||||
|
const hostname = bookmark.href ? getBookmarkHostname(bookmark.href) : undefined;
|
||||||
|
const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href);
|
||||||
|
const iconOnly = orientation === "icon" || (!showTitle && !showHostname);
|
||||||
|
const background = getBookmarkBackground(variant, active);
|
||||||
|
|
||||||
|
const content =
|
||||||
|
orientation === "vertical" && !advanced ? (
|
||||||
|
<Stack h="100%" align="center" justify="center" gap={6} flex={1}>
|
||||||
|
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={iconOnly ? 34 : 38} /> : null}
|
||||||
|
{showTitle ? (
|
||||||
|
<Text size="xxs" fw={600} lh={1.2} ta="center" lineClamp={2}>
|
||||||
|
{bookmark.name}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{showHostname ? (
|
||||||
|
<Text fz={9} c="dimmed" ta="center" truncate w="100%">
|
||||||
|
{hostname}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Group
|
||||||
|
h="100%"
|
||||||
|
gap={advanced ? "sm" : "xs"}
|
||||||
|
wrap="nowrap"
|
||||||
|
justify={iconOnly ? "center" : "flex-start"}
|
||||||
|
align="center"
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{showIcon ? (
|
||||||
|
<BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={advanced ? 42 : iconOnly ? 32 : 30} />
|
||||||
|
) : null}
|
||||||
|
{!iconOnly ? (
|
||||||
|
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
|
||||||
|
{showTitle ? (
|
||||||
|
<Text size={advanced ? "xs" : "xxs"} fw={600} lh={1.2} truncate>
|
||||||
|
{bookmark.name}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{showHostname ? (
|
||||||
|
<Text fz={advanced ? 10 : 9} c="dimmed" truncate>
|
||||||
|
{hostname}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{advanced && bookmark.description ? (
|
||||||
|
<Text fz={10} c="dimmed" lineClamp={2}>
|
||||||
|
{bookmark.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
{advanced ? (
|
||||||
|
<ThemeIcon
|
||||||
|
variant={active ? "light" : "transparent"}
|
||||||
|
color={active ? "primaryColor" : "gray"}
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
opacity: active ? 1 : 0.45,
|
||||||
|
transform: active && !reduceMotion ? "translate(1px, -1px)" : undefined,
|
||||||
|
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconArrowUpRight size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{bookmark.name}
|
||||||
|
</Text>
|
||||||
|
{hostname ? (
|
||||||
|
<Text size="xs" opacity={0.8}>
|
||||||
|
{hostname}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
openDelay={450}
|
||||||
|
disabled={advanced}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
component={href ? "a" : "div"}
|
||||||
|
href={href}
|
||||||
|
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||||
|
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||||
|
aria-label={bookmark.name}
|
||||||
|
radius={radius}
|
||||||
|
withBorder={withBorder || variant === "outline"}
|
||||||
|
p={iconOnly ? 6 : advanced ? "md" : "xs"}
|
||||||
|
h={height}
|
||||||
|
w={width}
|
||||||
|
miw={width}
|
||||||
|
onMouseEnter={() => setActive(true)}
|
||||||
|
onMouseLeave={() => setActive(false)}
|
||||||
|
onFocus={() => setActive(true)}
|
||||||
|
onBlur={() => setActive(false)}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
background,
|
||||||
|
borderColor: active
|
||||||
|
? "rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.62))"
|
||||||
|
: "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38))",
|
||||||
|
boxShadow: active ? "var(--mantine-shadow-sm)" : undefined,
|
||||||
|
color: "var(--mantine-color-text)",
|
||||||
|
cursor: href ? "pointer" : "default",
|
||||||
|
display: "flex",
|
||||||
|
outline: active ? "2px solid var(--mantine-primary-color-light)" : undefined,
|
||||||
|
outlineOffset: -2,
|
||||||
|
overflow: "hidden",
|
||||||
|
textDecoration: "none",
|
||||||
|
transform: active && !reduceMotion ? "translateY(-1px)" : undefined,
|
||||||
|
transition: reduceMotion
|
||||||
|
? undefined
|
||||||
|
: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => (
|
||||||
|
<Avatar
|
||||||
|
src={iconUrl}
|
||||||
|
name={bookmark.name}
|
||||||
|
color="gray"
|
||||||
|
radius="sm"
|
||||||
|
size={size}
|
||||||
|
imageProps={{ referrerPolicy: "no-referrer" }}
|
||||||
|
styles={{ image: { objectFit: "contain" } }}
|
||||||
|
>
|
||||||
|
<IconLink size={Math.max(14, size / 2)} />
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => {
|
||||||
|
if (variant === "plain" || variant === "outline") return "transparent";
|
||||||
|
if (variant === "filled") {
|
||||||
|
const opacity = active ? 0.64 : 0.48;
|
||||||
|
return `rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * ${opacity}))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opacity = active ? 0.14 : 0.08;
|
||||||
|
return `rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * ${opacity}))`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBookmarkHostname = (href: string): string | undefined => {
|
||||||
|
try {
|
||||||
|
return new URL(href).hostname || undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,525 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
export { default } from "./bookmarks-widget";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Anchor,
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
Flex,
|
|
||||||
Group,
|
|
||||||
ScrollArea,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconLink } from "@tabler/icons-react";
|
|
||||||
import combineClasses from "clsx";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
|
||||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
|
||||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
|
||||||
import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
|
||||||
import classes from "./bookmark.module.css";
|
|
||||||
|
|
||||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
|
||||||
|
|
||||||
export interface BookmarkDisplayItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
iconUrl?: string;
|
|
||||||
href: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompactBookmarkLayout {
|
|
||||||
columns: number;
|
|
||||||
hideHostname: boolean;
|
|
||||||
hideTitle: boolean;
|
|
||||||
minimumItemSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCompactBookmarkLayout(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
itemCount: number,
|
|
||||||
layout: BookmarkLayout,
|
|
||||||
): CompactBookmarkLayout {
|
|
||||||
const count = Math.max(1, itemCount);
|
|
||||||
const isGrid = layout === "grid" || layout === "gridHorizontal";
|
|
||||||
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
|
|
||||||
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
|
|
||||||
const rows = Math.max(1, Math.ceil(count / columns));
|
|
||||||
const cellWidth = width / columns;
|
|
||||||
const cellHeight = height / rows;
|
|
||||||
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
|
|
||||||
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
|
|
||||||
hideTitle: cellWidth < 64 || cellHeight < 40,
|
|
||||||
minimumItemSize: usesHorizontalItems ? 48 : 96,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookmarksWidget({
|
|
||||||
options,
|
|
||||||
itemId,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
displayMode,
|
|
||||||
}: WidgetComponentProps<"bookmarks">) {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
const apps =
|
|
||||||
getUsableWidgetQueryData(
|
|
||||||
clientApi.app.byIds.useQuery(options.items, {
|
|
||||||
select(selectedApps) {
|
|
||||||
return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
) ?? [];
|
|
||||||
const customLinks = useMemo(
|
|
||||||
() => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
|
|
||||||
[options.customUrls],
|
|
||||||
);
|
|
||||||
const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
|
|
||||||
|
|
||||||
useRegisterSpotlightContextResults(
|
|
||||||
`bookmark-${itemId}`,
|
|
||||||
data.flatMap((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return href
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: app.id,
|
|
||||||
name: app.name,
|
|
||||||
icon: app.iconUrl ?? IconLink,
|
|
||||||
interaction() {
|
|
||||||
return {
|
|
||||||
type: "link",
|
|
||||||
href,
|
|
||||||
newTab: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
}),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const compactLayout = useMemo(
|
|
||||||
() => getCompactBookmarkLayout(width, height, data.length, options.layout),
|
|
||||||
[width, height, data.length, options.layout],
|
|
||||||
);
|
|
||||||
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
|
|
||||||
const compactHideHostname =
|
|
||||||
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
|
|
||||||
{options.title.length > 0 && (
|
|
||||||
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
|
|
||||||
{options.title}
|
|
||||||
</Title>
|
|
||||||
)}
|
|
||||||
{displayMode === "advanced" && (
|
|
||||||
<AdvancedBookmarksLayout
|
|
||||||
data={data}
|
|
||||||
width={width}
|
|
||||||
openNewTab={options.openNewTab}
|
|
||||||
withBorder={options.withBorder}
|
|
||||||
hasIconColor={board.iconColor !== null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{displayMode !== "advanced" && (
|
|
||||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<Box mih="100%">
|
|
||||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
|
||||||
<GridLayout
|
|
||||||
data={data}
|
|
||||||
columns={compactLayout.columns}
|
|
||||||
minimumItemHeight={compactLayout.minimumItemSize}
|
|
||||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
|
||||||
hideTitle={compactHideTitle}
|
|
||||||
hideIcon={options.hideIcon}
|
|
||||||
hideHostname={compactHideHostname}
|
|
||||||
openNewTab={options.openNewTab}
|
|
||||||
withBorder={options.withBorder}
|
|
||||||
hasIconColor={board.iconColor !== null}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FlexLayout
|
|
||||||
data={data}
|
|
||||||
direction={options.layout}
|
|
||||||
minimumItemSize={compactLayout.minimumItemSize}
|
|
||||||
hideTitle={compactHideTitle}
|
|
||||||
hideIcon={options.hideIcon}
|
|
||||||
hideHostname={compactHideHostname}
|
|
||||||
openNewTab={options.openNewTab}
|
|
||||||
withBorder={options.withBorder}
|
|
||||||
hasIconColor={board.iconColor !== null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlexLayoutProps {
|
|
||||||
data: BookmarkDisplayItem[];
|
|
||||||
direction: "row" | "column";
|
|
||||||
hideTitle: boolean;
|
|
||||||
hideIcon: boolean;
|
|
||||||
hideHostname: boolean;
|
|
||||||
openNewTab: boolean;
|
|
||||||
withBorder: boolean;
|
|
||||||
hasIconColor: boolean;
|
|
||||||
minimumItemSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FlexLayout = ({
|
|
||||||
data,
|
|
||||||
direction,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
openNewTab,
|
|
||||||
withBorder,
|
|
||||||
hasIconColor,
|
|
||||||
minimumItemSize,
|
|
||||||
}: FlexLayoutProps) => {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
return (
|
|
||||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
|
||||||
{data.map((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return (
|
|
||||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
|
||||||
<UnstyledButton
|
|
||||||
className={classes.bookmarkButton}
|
|
||||||
component={href ? "a" : "div"}
|
|
||||||
href={href}
|
|
||||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
|
||||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
|
||||||
key={app.id}
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
radius={board.itemRadius}
|
|
||||||
className={classes.card}
|
|
||||||
w="100%"
|
|
||||||
display="flex"
|
|
||||||
p={4}
|
|
||||||
h="100%"
|
|
||||||
withBorder={withBorder}
|
|
||||||
>
|
|
||||||
{direction === "row" ? (
|
|
||||||
<VerticalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<HorizontalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AdvancedBookmarksLayoutProps {
|
|
||||||
data: BookmarkDisplayItem[];
|
|
||||||
width: number;
|
|
||||||
openNewTab: boolean;
|
|
||||||
withBorder: boolean;
|
|
||||||
hasIconColor: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
|
|
||||||
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
|
|
||||||
|
|
||||||
export const getBookmarkHostname = (href: string | null): string | undefined => {
|
|
||||||
const safeHref = getSafeApplicationUrl(href);
|
|
||||||
return safeHref ? new URL(safeHref).hostname : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
|
|
||||||
const normalizedUrl = url.trim();
|
|
||||||
const href = getSafeAppHref(normalizedUrl);
|
|
||||||
if (!href) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `custom-link:${normalizedUrl}`,
|
|
||||||
name: getBookmarkHostname(href) ?? normalizedUrl,
|
|
||||||
description: null,
|
|
||||||
href,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdvancedBookmarksLayout = ({
|
|
||||||
data,
|
|
||||||
width,
|
|
||||||
openNewTab,
|
|
||||||
withBorder,
|
|
||||||
hasIconColor,
|
|
||||||
}: AdvancedBookmarksLayoutProps) => {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
|
||||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
|
||||||
{data.map((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
key={app.id}
|
|
||||||
className={classes.bookmarkButton}
|
|
||||||
component={href ? "a" : "div"}
|
|
||||||
href={href}
|
|
||||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
|
||||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
|
||||||
>
|
|
||||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
|
||||||
<Group align="flex-start" wrap="nowrap">
|
|
||||||
<BookmarkIcon
|
|
||||||
app={app}
|
|
||||||
hasColor={hasIconColor}
|
|
||||||
className={classes.bookmarkIcon}
|
|
||||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
|
||||||
/>
|
|
||||||
<Stack gap={3} miw={0}>
|
|
||||||
<Text fw={700} size="sm">
|
|
||||||
{app.name}
|
|
||||||
</Text>
|
|
||||||
<Anchor component="span" size="xs" truncate="end">
|
|
||||||
{getBookmarkHostname(app.href)}
|
|
||||||
</Anchor>
|
|
||||||
{app.description && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{app.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GridLayoutProps {
|
|
||||||
data: BookmarkDisplayItem[];
|
|
||||||
hideTitle: boolean;
|
|
||||||
hideIcon: boolean;
|
|
||||||
hideHostname: boolean;
|
|
||||||
openNewTab: boolean;
|
|
||||||
withBorder: boolean;
|
|
||||||
itemDirection: "horizontal" | "vertical";
|
|
||||||
hasIconColor: boolean;
|
|
||||||
columns: number;
|
|
||||||
minimumItemHeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GridLayout = ({
|
|
||||||
data,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
openNewTab,
|
|
||||||
withBorder,
|
|
||||||
itemDirection,
|
|
||||||
hasIconColor,
|
|
||||||
columns,
|
|
||||||
minimumItemHeight,
|
|
||||||
}: GridLayoutProps) => {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SimpleGrid
|
|
||||||
cols={columns}
|
|
||||||
spacing={4}
|
|
||||||
verticalSpacing={4}
|
|
||||||
miw="100%"
|
|
||||||
mih="100%"
|
|
||||||
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
|
|
||||||
>
|
|
||||||
{data.map((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
className={classes.bookmarkButton}
|
|
||||||
component={href ? "a" : "div"}
|
|
||||||
href={href}
|
|
||||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
|
||||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
|
||||||
key={app.id}
|
|
||||||
h="100%"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
h="100%"
|
|
||||||
className={combineClasses(classes.card, classes["card-grid"])}
|
|
||||||
radius={board.itemRadius}
|
|
||||||
withBorder={withBorder}
|
|
||||||
p="xs"
|
|
||||||
>
|
|
||||||
{itemDirection === "horizontal" ? (
|
|
||||||
<HorizontalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VerticalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VerticalItem = ({
|
|
||||||
app,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
hasIconColor,
|
|
||||||
}: {
|
|
||||||
app: BookmarkDisplayItem;
|
|
||||||
hideTitle: boolean;
|
|
||||||
hideIcon: boolean;
|
|
||||||
hideHostname: boolean;
|
|
||||||
hasIconColor: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
|
|
||||||
{!hideTitle && (
|
|
||||||
<Text fw={700} ta="center" size="xs" lineClamp={2}>
|
|
||||||
{app.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{!hideIcon && (
|
|
||||||
<BookmarkIcon
|
|
||||||
app={app}
|
|
||||||
hasColor={hasIconColor}
|
|
||||||
className={classes.bookmarkIcon}
|
|
||||||
style={{
|
|
||||||
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
|
||||||
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
|
||||||
overflow: "auto",
|
|
||||||
flex: "unset",
|
|
||||||
marginLeft: "auto",
|
|
||||||
marginRight: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hideHostname && (
|
|
||||||
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
|
|
||||||
{getBookmarkHostname(app.href)}
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const HorizontalItem = ({
|
|
||||||
app,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
hasIconColor,
|
|
||||||
}: {
|
|
||||||
app: BookmarkDisplayItem;
|
|
||||||
hideTitle: boolean;
|
|
||||||
hideIcon: boolean;
|
|
||||||
hideHostname: boolean;
|
|
||||||
hasIconColor: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
|
|
||||||
{!hideIcon && (
|
|
||||||
<BookmarkIcon
|
|
||||||
app={app}
|
|
||||||
hasColor={hasIconColor}
|
|
||||||
className={classes.bookmarkIcon}
|
|
||||||
style={{
|
|
||||||
overflow: "auto",
|
|
||||||
width: hideHostname ? 16 : 24,
|
|
||||||
height: hideHostname ? 16 : 24,
|
|
||||||
flex: "unset",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!(hideTitle && hideHostname) && (
|
|
||||||
<>
|
|
||||||
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
|
|
||||||
{!hideTitle && (
|
|
||||||
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
|
|
||||||
{app.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hideHostname && (
|
|
||||||
<Anchor component="span" size="xs" truncate="end">
|
|
||||||
{getBookmarkHostname(app.href)}
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BookmarkIconProps {
|
|
||||||
app: BookmarkDisplayItem;
|
|
||||||
hasColor: boolean;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
|
|
||||||
app.iconUrl ? (
|
|
||||||
<MaskedOrNormalImage
|
|
||||||
imageUrl={app.iconUrl}
|
|
||||||
hasColor={hasColor}
|
|
||||||
alt={app.name}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconLink aria-label={app.name} className={className} style={style} />
|
|
||||||
);
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import { getSafeAppHref } from "../common/application-url";
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
import { BookmarkAddButton } from "./add-button";
|
import { BookmarkAddButton } from "./add-button";
|
||||||
|
import type { BookmarkSelectionItem } from "./add-button";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
@@ -18,42 +17,63 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
title: factory.text(),
|
title: factory.text(),
|
||||||
layout: factory.select({
|
layout: factory.select({
|
||||||
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
|
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: (t) => {
|
label: (t) => {
|
||||||
|
if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label");
|
||||||
if (value === "grid") return t("widget.common.layout.option.grid");
|
if (value === "grid") return t("widget.common.layout.option.grid");
|
||||||
if (value === "row") return t("widget.common.layout.option.horizontal");
|
if (value === "row") return t("widget.common.layout.option.horizontal");
|
||||||
if (value === "column") return t("widget.common.layout.option.vertical");
|
if (value === "column") return t("widget.common.layout.option.vertical");
|
||||||
|
if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label");
|
||||||
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
|
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
defaultValue: "column",
|
defaultValue: "adaptive",
|
||||||
|
}),
|
||||||
|
variant: factory.select({
|
||||||
|
options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`),
|
||||||
|
})),
|
||||||
|
defaultValue: "soft",
|
||||||
|
}),
|
||||||
|
spacing: factory.select({
|
||||||
|
options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`),
|
||||||
|
})),
|
||||||
|
defaultValue: "xs",
|
||||||
}),
|
}),
|
||||||
hideTitle: factory.switch({ defaultValue: false }),
|
hideTitle: factory.switch({ defaultValue: false }),
|
||||||
hideIcon: factory.switch({ defaultValue: false }),
|
hideIcon: factory.switch({ defaultValue: false }),
|
||||||
hideHostname: factory.switch({ defaultValue: false }),
|
hideHostname: factory.switch({ defaultValue: false }),
|
||||||
openNewTab: factory.switch({ defaultValue: true }),
|
openNewTab: factory.switch({ defaultValue: true }),
|
||||||
withBorder: factory.switch({ defaultValue: false }),
|
withBorder: factory.switch({ defaultValue: false }),
|
||||||
customUrls: factory.multiText({
|
customUrls: factory.internal({ defaultValue: [] as string[] }),
|
||||||
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
|
items: factory.sortableItemList<BookmarkSelectionItem, string>({
|
||||||
withDescription: true,
|
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
|
||||||
}),
|
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
|
||||||
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
|
|
||||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
|
||||||
return (
|
return (
|
||||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap" miw={0}>
|
||||||
{handle}
|
{handle}
|
||||||
|
|
||||||
<Group>
|
<Group wrap="nowrap" miw={0}>
|
||||||
<Avatar src={item.iconUrl} alt={item.name} />
|
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
|
||||||
<Stack gap={0}>
|
<IconLink size={20} />
|
||||||
<Text>{item.name}</Text>
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0}>
|
||||||
|
<Text fw={600} truncate>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{item.href}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
|
||||||
<IconX size="var(--mantine-font-size-xl)" />
|
<IconX size="var(--mantine-font-size-xl)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -62,7 +82,14 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
AddButton: BookmarkAddButton,
|
AddButton: BookmarkAddButton,
|
||||||
uniqueIdentifier: (item) => item.id,
|
uniqueIdentifier: (item) => item.id,
|
||||||
useData: (initialIds) => {
|
useData: (initialIds) => {
|
||||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value));
|
||||||
|
const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds);
|
||||||
|
const appsById = new Map(apps?.map((app) => [app.id, app]));
|
||||||
|
const data = initialIds.flatMap((value) => {
|
||||||
|
const directUrl = getDirectBookmarkUrl(value);
|
||||||
|
if (directUrl) return createDirectBookmark(directUrl) ?? [];
|
||||||
|
return appsById.get(value) ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,54 +1,69 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||||
createCustomBookmark,
|
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||||
getAdvancedBookmarkColumns,
|
|
||||||
getBookmarkHostname,
|
|
||||||
getCompactBookmarkLayout,
|
|
||||||
} from "./component";
|
|
||||||
|
|
||||||
describe("bookmark display helpers", () => {
|
describe("bookmark display helpers", () => {
|
||||||
test("uses bounded advanced columns", () => {
|
test("uses explicit width breakpoints for advanced columns", () => {
|
||||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
expect(
|
||||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
).toMatchObject({
|
||||||
|
columns: 3,
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||||
|
).toMatchObject({
|
||||||
|
columns: 1,
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses container geometry to keep compact cards legible", () => {
|
test("selects an adaptive plan from width and height breakpoints", () => {
|
||||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
expect(
|
||||||
|
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
|
||||||
|
).toMatchObject({
|
||||||
columns: 2,
|
columns: 2,
|
||||||
hideHostname: false,
|
itemHeight: 128,
|
||||||
hideTitle: false,
|
orientation: "horizontal",
|
||||||
minimumItemSize: 48,
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
|
||||||
columns: 4,
|
).toMatchObject({
|
||||||
hideHostname: true,
|
columns: 1,
|
||||||
hideTitle: true,
|
orientation: "icon",
|
||||||
minimumItemSize: 96,
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not crash on malformed bookmark URLs", () => {
|
test("keeps compact cards visible when icons are hidden", () => {
|
||||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
|
||||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
expect(
|
||||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
|
||||||
|
).toEqual({
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showIcon: false,
|
||||||
|
showTitle: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates standalone bookmarks without app records", () => {
|
test("creates standalone bookmarks without app records", () => {
|
||||||
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
|
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
|
||||||
id: "custom-link:https://homarr.dev/docs",
|
id: "url:https://homarr.dev/docs",
|
||||||
name: "homarr.dev",
|
name: "homarr.dev",
|
||||||
description: null,
|
description: null,
|
||||||
href: "https://homarr.dev/docs",
|
href: "https://homarr.dev/docs",
|
||||||
});
|
});
|
||||||
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
|
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
|
||||||
id: "custom-link:https://homarr.dev/docs",
|
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
|
||||||
name: "homarr.dev",
|
});
|
||||||
description: null,
|
|
||||||
href: "https://homarr.dev/docs",
|
test("splits URL lists without corrupting commas inside a URL", () => {
|
||||||
});
|
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
|
||||||
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
|
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||||
|
export type BookmarkOrientation = "horizontal" | "vertical" | "icon";
|
||||||
|
|
||||||
|
export interface BookmarkDisplayPlan {
|
||||||
|
columns: number;
|
||||||
|
horizontalScroll: boolean;
|
||||||
|
itemHeight: number;
|
||||||
|
itemWidth: number;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkCardDisplay {
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
showHostname: boolean;
|
||||||
|
showIcon: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBookmarkCardDisplay = ({
|
||||||
|
advanced,
|
||||||
|
hideHostname,
|
||||||
|
hideIcon,
|
||||||
|
hideTitle,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
advanced: boolean;
|
||||||
|
hideHostname: boolean;
|
||||||
|
hideIcon: boolean;
|
||||||
|
hideTitle: boolean;
|
||||||
|
plan: BookmarkDisplayPlan;
|
||||||
|
}): BookmarkCardDisplay => {
|
||||||
|
let orientation = plan.orientation;
|
||||||
|
let showTitle = advanced || (!hideTitle && plan.showTitle);
|
||||||
|
if (!advanced && hideIcon && !hideTitle && !showTitle) {
|
||||||
|
orientation = "horizontal";
|
||||||
|
showTitle = true;
|
||||||
|
}
|
||||||
|
const showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||||
|
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
|
||||||
|
|
||||||
|
return { orientation, showHostname, showIcon, showTitle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BookmarkWidthBreakpoint {
|
||||||
|
adaptiveColumns: number;
|
||||||
|
adaptiveOrientation: BookmarkOrientation;
|
||||||
|
advancedColumns: number;
|
||||||
|
compactColumns: number;
|
||||||
|
gridColumns: number;
|
||||||
|
iconColumns: number;
|
||||||
|
minWidth: number;
|
||||||
|
rowItemWidth: number;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthBreakpoints = [
|
||||||
|
{
|
||||||
|
minWidth: 900,
|
||||||
|
adaptiveColumns: 4,
|
||||||
|
advancedColumns: 3,
|
||||||
|
compactColumns: 4,
|
||||||
|
gridColumns: 5,
|
||||||
|
iconColumns: 10,
|
||||||
|
rowItemWidth: 180,
|
||||||
|
adaptiveOrientation: "vertical",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 720,
|
||||||
|
adaptiveColumns: 3,
|
||||||
|
advancedColumns: 2,
|
||||||
|
compactColumns: 3,
|
||||||
|
gridColumns: 4,
|
||||||
|
iconColumns: 8,
|
||||||
|
rowItemWidth: 172,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 480,
|
||||||
|
adaptiveColumns: 2,
|
||||||
|
advancedColumns: 2,
|
||||||
|
compactColumns: 2,
|
||||||
|
gridColumns: 3,
|
||||||
|
iconColumns: 6,
|
||||||
|
rowItemWidth: 160,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 300,
|
||||||
|
adaptiveColumns: 2,
|
||||||
|
advancedColumns: 1,
|
||||||
|
compactColumns: 1,
|
||||||
|
gridColumns: 2,
|
||||||
|
iconColumns: 4,
|
||||||
|
rowItemWidth: 144,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
adaptiveColumns: 1,
|
||||||
|
advancedColumns: 1,
|
||||||
|
compactColumns: 1,
|
||||||
|
gridColumns: 1,
|
||||||
|
iconColumns: 2,
|
||||||
|
rowItemWidth: 112,
|
||||||
|
adaptiveOrientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly BookmarkWidthBreakpoint[];
|
||||||
|
|
||||||
|
interface BookmarkHeightBreakpoint {
|
||||||
|
columnItemHeight: number;
|
||||||
|
itemHeight: number;
|
||||||
|
minHeight: number;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
rowItemHeight: number;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightBreakpoints = [
|
||||||
|
{
|
||||||
|
minHeight: 300,
|
||||||
|
columnItemHeight: 72,
|
||||||
|
itemHeight: 128,
|
||||||
|
rowItemHeight: 128,
|
||||||
|
orientation: "vertical",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 180,
|
||||||
|
columnItemHeight: 64,
|
||||||
|
itemHeight: 128,
|
||||||
|
rowItemHeight: 112,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 110,
|
||||||
|
columnItemHeight: 56,
|
||||||
|
itemHeight: 80,
|
||||||
|
rowItemHeight: 80,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 0,
|
||||||
|
columnItemHeight: 48,
|
||||||
|
itemHeight: 56,
|
||||||
|
rowItemHeight: 56,
|
||||||
|
orientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly BookmarkHeightBreakpoint[];
|
||||||
|
|
||||||
|
export const getBookmarkDisplayPlan = ({
|
||||||
|
advanced,
|
||||||
|
height,
|
||||||
|
itemCount,
|
||||||
|
layout,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
advanced: boolean;
|
||||||
|
height: number;
|
||||||
|
itemCount: number;
|
||||||
|
layout: BookmarkLayout;
|
||||||
|
width: number;
|
||||||
|
}): BookmarkDisplayPlan => {
|
||||||
|
const count = Math.max(1, itemCount);
|
||||||
|
const normalizedWidth = Math.max(0, width);
|
||||||
|
const normalizedHeight = Math.max(0, height);
|
||||||
|
const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth);
|
||||||
|
const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight);
|
||||||
|
if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes");
|
||||||
|
|
||||||
|
if (advanced) {
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, widthSettings.advancedColumns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: 104,
|
||||||
|
itemWidth: 260,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "row") {
|
||||||
|
let orientation: BookmarkOrientation = "vertical";
|
||||||
|
if (!heightSettings.showTitle) orientation = "icon";
|
||||||
|
return {
|
||||||
|
columns: count,
|
||||||
|
horizontalScroll: true,
|
||||||
|
itemHeight: heightSettings.rowItemHeight,
|
||||||
|
itemWidth: widthSettings.rowItemWidth,
|
||||||
|
orientation,
|
||||||
|
showHostname: widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "column") {
|
||||||
|
return {
|
||||||
|
columns: 1,
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: heightSettings.columnItemHeight,
|
||||||
|
itemWidth: width,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "icons") {
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, widthSettings.iconColumns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: 56,
|
||||||
|
itemWidth: 56,
|
||||||
|
orientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let columns: number = widthSettings.compactColumns;
|
||||||
|
let orientation: BookmarkOrientation = "horizontal";
|
||||||
|
if (layout === "grid") {
|
||||||
|
columns = widthSettings.gridColumns;
|
||||||
|
orientation = "vertical";
|
||||||
|
}
|
||||||
|
if (layout === "adaptive") {
|
||||||
|
columns = widthSettings.adaptiveColumns;
|
||||||
|
orientation = widthSettings.adaptiveOrientation;
|
||||||
|
if (heightSettings.orientation === "icon") orientation = "icon";
|
||||||
|
if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, columns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: heightSettings.itemHeight,
|
||||||
|
itemWidth: widthSettings.rowItemWidth,
|
||||||
|
orientation,
|
||||||
|
showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,11 +4,12 @@ import { apps } from "@homarr/db/schema";
|
|||||||
|
|
||||||
import type { Prefetch } from "../definition";
|
import type { Prefetch } from "../definition";
|
||||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||||
|
import { getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
|
||||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||||
|
|
||||||
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||||
const appIds = items.flatMap((item) => item.options.items);
|
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
|
||||||
const distinctAppIds = [...new Set(appIds)];
|
const distinctAppIds = [...new Set(appIds)];
|
||||||
|
|
||||||
const dbApps = await db.query.apps.findMany({
|
const dbApps = await db.query.apps.findMany({
|
||||||
@@ -16,13 +17,14 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.options.items.length === 0) {
|
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
|
||||||
|
if (itemAppIds.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
createTrpcQueryKey("app.byIds", item.options.items),
|
createTrpcQueryKey("app.byIds", itemAppIds),
|
||||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
dbApps.filter((app) => itemAppIds.includes(app.id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.errorIndicator {
|
||||||
|
top: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xl));
|
||||||
|
inset-inline-end: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.45));
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
background-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.18));
|
||||||
|
}
|
||||||
@@ -180,14 +180,10 @@ const CalendarBase = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
||||||
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
|
|
||||||
<Group px="xs" justify="flex-end">
|
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
<Calendar
|
<Calendar
|
||||||
defaultDate={new Date()}
|
defaultDate={new Date()}
|
||||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||||
@@ -260,7 +256,13 @@ const CalendarBase = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
{hasErrors && (
|
||||||
|
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||||
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
|
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -135,14 +135,13 @@ export function HomarrDataTable<T>({
|
|||||||
withTableBorder={false}
|
withTableBorder={false}
|
||||||
borderRadius={0}
|
borderRadius={0}
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
striped="odd"
|
|
||||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
|
||||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||||
verticalAlign="center"
|
verticalAlign="center"
|
||||||
loaderBackgroundBlur={2}
|
loaderBackgroundBlur={2}
|
||||||
textSelectionDisabled
|
textSelectionDisabled
|
||||||
height="100%"
|
height="100%"
|
||||||
{...props}
|
{...props}
|
||||||
|
striped={false}
|
||||||
columns={fittedColumns}
|
columns={fittedColumns}
|
||||||
onMouseDownCapture={handleMouseDownCapture}
|
onMouseDownCapture={handleMouseDownCapture}
|
||||||
className={mergedClassName}
|
className={mergedClassName}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.accordion {
|
||||||
|
--coolify-neutral-fill: rgb(
|
||||||
|
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12)
|
||||||
|
);
|
||||||
|
--coolify-neutral-border: rgb(
|
||||||
|
from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion :global(.mantine-Accordion-item) {
|
||||||
|
--item-border-color: var(--coolify-neutral-border);
|
||||||
|
--item-filled-color: var(--coolify-neutral-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion :global(.mantine-Accordion-control) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion :global(.mantine-Accordion-control:not(:disabled, [data-disabled]):hover) {
|
||||||
|
background-color: var(--coolify-neutral-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutralDividerBottom {
|
||||||
|
border-bottom: 1px solid var(--coolify-neutral-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutralDividerTop {
|
||||||
|
border-top: 1px solid var(--coolify-neutral-border);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { ApplicationsSection } from "./applications-section";
|
import { ApplicationsSection } from "./applications-section";
|
||||||
|
import classes from "./component.module.css";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
|
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
|
||||||
import { ServersSection } from "./servers-section";
|
import { ServersSection } from "./servers-section";
|
||||||
@@ -71,12 +72,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
|||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card p={0} radius="sm">
|
<Card p={0} radius="sm" bg="transparent">
|
||||||
<Group
|
<Group
|
||||||
p="xs"
|
p="xs"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
|
className={classes.neutralDividerBottom}
|
||||||
>
|
>
|
||||||
<Group gap={4} wrap="nowrap" miw={0}>
|
<Group gap={4} wrap="nowrap" miw={0}>
|
||||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||||
@@ -135,6 +136,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Accordion
|
<Accordion
|
||||||
|
className={classes.accordion}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
chevronPosition="right"
|
chevronPosition="right"
|
||||||
multiple
|
multiple
|
||||||
@@ -175,11 +177,7 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
|
|||||||
const t = useI18n("widget.coolify");
|
const t = useI18n("widget.coolify");
|
||||||
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
|
||||||
justify="space-between"
|
|
||||||
p={4}
|
|
||||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
|
||||||
>
|
|
||||||
<Text size="10px" c="dimmed">
|
<Text size="10px" c="dimmed">
|
||||||
v{version}
|
v{version}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { ApplicationsSection } from "./applications-section";
|
import { ApplicationsSection } from "./applications-section";
|
||||||
|
import classes from "./component.module.css";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { buildServerResourceCounts } from "./coolify-utils";
|
import { buildServerResourceCounts } from "./coolify-utils";
|
||||||
import { ServersSection } from "./servers-section";
|
import { ServersSection } from "./servers-section";
|
||||||
@@ -82,6 +83,7 @@ export function SingleInstanceLayout({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Accordion
|
<Accordion
|
||||||
|
className={classes.accordion}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
chevronPosition="right"
|
chevronPosition="right"
|
||||||
multiple
|
multiple
|
||||||
@@ -123,11 +125,7 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
|
|||||||
const t = useI18n("widget.coolify");
|
const t = useI18n("widget.coolify");
|
||||||
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
|
||||||
justify="space-between"
|
|
||||||
p={4}
|
|
||||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
|
||||||
>
|
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
[data-mantine-color-scheme="light"] .card {
|
.card {
|
||||||
background-color: var(--mantine-color-gray-1);
|
background-color: transparent;
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .card {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
}
|
||||||
@@ -195,6 +195,9 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const label = translateIfNecessary(t, item.label);
|
const label = translateIfNecessary(t, item.label);
|
||||||
const value = item.value(data, width <= 64 ? "sm" : "md");
|
const value = item.value(data, width <= 64 ? "sm" : "md");
|
||||||
|
const backgroundColor = usePiHoleColors
|
||||||
|
? `rgb(from ${item.color} r g b / calc(var(--opacity, 1) * 0.4))`
|
||||||
|
: "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
|
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
|
||||||
@@ -206,9 +209,11 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
|||||||
className="summary-card"
|
className="summary-card"
|
||||||
p="sm"
|
p="sm"
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
|
bg={backgroundColor}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
border:
|
||||||
|
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -146,8 +148,9 @@ const createColumns = (
|
|||||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||||
{inlineState && <ContainerStateDot state={container.state} />}
|
{inlineState && <ContainerStateDot state={container.state} />}
|
||||||
<Avatar
|
<Avatar
|
||||||
radius="xl"
|
variant="outline"
|
||||||
size={24}
|
radius="sm"
|
||||||
|
size={20}
|
||||||
styles={{ image: { objectFit: "contain" } }}
|
styles={{ image: { objectFit: "contain" } }}
|
||||||
src={container.iconUrl}
|
src={container.iconUrl}
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
@@ -432,6 +435,7 @@ export default function DockerWidget({
|
|||||||
<HomarrDataTable
|
<HomarrDataTable
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
cellPadding="2px 8px"
|
cellPadding="2px 8px"
|
||||||
|
className="docker-table"
|
||||||
rowCursor="default"
|
rowCursor="default"
|
||||||
fetching={isFetching && containers.length === 0}
|
fetching={isFetching && containers.length === 0}
|
||||||
fz={width < 400 ? "xs" : "sm"}
|
fz={width < 400 ? "xs" : "sm"}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* Keep Docker rows translucent so board wallpaper and the inherited item opacity remain visible. */
|
||||||
|
.homarr-data-table.docker-table {
|
||||||
|
--mantine-datatable-background-color: transparent !important;
|
||||||
|
--mantine-datatable-striped-color: transparent !important;
|
||||||
|
}
|
||||||
@@ -143,11 +143,6 @@ const stateColorMap: Record<DownloadState, string> = {
|
|||||||
unknown: "gray",
|
unknown: "gray",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
|
|
||||||
failed: "var(--mantine-color-red-light)",
|
|
||||||
paused: "var(--mantine-color-yellow-light)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
||||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||||
ratio: (ctx) => ctx.hasTorrents,
|
ratio: (ctx) => ctx.hasTorrents,
|
||||||
@@ -606,7 +601,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.state.columnTitle"),
|
title: t("items.state.columnTitle"),
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<Badge size="xs" variant="light" color={stateColorMap[record.state]}>
|
<Badge size="xs" variant="dot" color={stateColorMap[record.state]}>
|
||||||
{t(`states.${record.state}`)}
|
{t(`states.${record.state}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
@@ -784,7 +779,6 @@ export default function DownloadClientsWidget({
|
|||||||
className="downloads-table"
|
className="downloads-table"
|
||||||
customRowAttributes={(_, index) => ({ "data-row-index": index })}
|
customRowAttributes={(_, index) => ({ "data-row-index": index })}
|
||||||
onRowContextMenu={rowContextMenuHandler}
|
onRowContextMenu={rowContextMenuHandler}
|
||||||
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
|
|
||||||
rowExpansion={{
|
rowExpansion={{
|
||||||
trigger: "click",
|
trigger: "click",
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
@@ -900,7 +894,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
|||||||
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
|
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
<Badge size="xs" variant="dot" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
||||||
{t(`states.${item.state}`)}
|
{t(`states.${item.state}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { WidgetComponentProps } from "../definition";
|
|||||||
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
|
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
|
||||||
import { FirewallMenu } from "./firewall-menu";
|
import { FirewallMenu } from "./firewall-menu";
|
||||||
import { FirewallVersion, formatVersion } from "./firewall-version";
|
import { FirewallVersion, formatVersion } from "./firewall-version";
|
||||||
|
import classes from "./firewall.module.css";
|
||||||
|
|
||||||
export interface Firewall {
|
export interface Firewall {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -243,7 +244,7 @@ const FirewallPanel = ({
|
|||||||
t,
|
t,
|
||||||
tCommon,
|
tCommon,
|
||||||
}: FirewallPanelProps) => (
|
}: FirewallPanelProps) => (
|
||||||
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
|
<Paper className={classes.card} withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0} bg="transparent">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{isAdvanced && (
|
{isAdvanced && (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
@@ -382,6 +383,7 @@ const InterfacesPanel = ({
|
|||||||
value={isAdvanced ? "interfaces" : accordionValue}
|
value={isAdvanced ? "interfaces" : accordionValue}
|
||||||
onChange={isAdvanced ? undefined : setAccordionValue}
|
onChange={isAdvanced ? undefined : setAccordionValue}
|
||||||
variant={isAdvanced ? "contained" : "default"}
|
variant={isAdvanced ? "contained" : "default"}
|
||||||
|
classNames={{ item: classes.accordionItem, control: classes.accordionControl }}
|
||||||
>
|
>
|
||||||
<Accordion.Item value="interfaces">
|
<Accordion.Item value="interfaces">
|
||||||
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
|
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Select } from "@mantine/core";
|
import { Box, Select } from "@mantine/core";
|
||||||
|
|
||||||
import type { Firewall } from "./component";
|
import type { Firewall } from "./component";
|
||||||
|
import classes from "./firewall.module.css";
|
||||||
|
|
||||||
interface FirewallMenuProps {
|
interface FirewallMenuProps {
|
||||||
onChange: (value: string | null) => void;
|
onChange: (value: string | null) => void;
|
||||||
@@ -18,6 +19,7 @@ export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall
|
|||||||
w="100%"
|
w="100%"
|
||||||
color="lightgray"
|
color="lightgray"
|
||||||
data={dropdownItems}
|
data={dropdownItems}
|
||||||
|
classNames={{ input: classes.selectInput }}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
minHeight: "24px",
|
minHeight: "24px",
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
[data-mantine-color-scheme="light"] .card {
|
.card {
|
||||||
background-color: var(--mantine-color-gray-1);
|
background-color: transparent;
|
||||||
|
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .card {
|
.accordionItem {
|
||||||
background-color: var(--mantine-color-dark-7);
|
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordionItem[data-active] {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordionControl {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordionControl:hover,
|
||||||
|
.accordionControl:focus-visible,
|
||||||
|
.accordionControl:active {
|
||||||
|
background-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectInput {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectInput:focus,
|
||||||
|
.selectInput:focus-within {
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
[data-mantine-color-scheme="light"] .card {
|
.card {
|
||||||
background-color: var(--mantine-color-gray-1);
|
background-color: transparent;
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .card {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.systemPanel {
|
.systemPanel {
|
||||||
|
|||||||