Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f86eb5731 | ||
|
|
88541b013d |
No files matched your search
@@ -194,15 +194,11 @@ 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>
|
||||
</figure>
|
||||
|
||||
The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
|
||||
In the modal you can edit the app that you want to display:
|
||||
|
||||
<figure>
|
||||
<img
|
||||
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>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
</figure>
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
|
||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 68 KiB |
@@ -27,10 +27,9 @@ 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.
|
||||
|
||||
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.
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows
|
||||
structural previews of the other boards you can access. 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Bookmarks"
|
||||
description: "Keeps useful links together"
|
||||
description: "Displays multiple app links"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||
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.
|
||||
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||
|
||||
**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.
|
||||
The bookmarks widget allows you to display multiple links in a single widget.
|
||||
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.
|
||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||
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.
|
||||
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.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
||||
export const bookmarksWidget: WidgetDefinition = {
|
||||
icon: IconBookmark,
|
||||
name: "Bookmarks",
|
||||
description: "Keeps useful links together",
|
||||
description: "Displays multiple app links",
|
||||
path: "../../widgets/bookmarks",
|
||||
configuration: {
|
||||
items: [
|
||||
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "How bookmarks use the available widget space.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
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",
|
||||
description: "Layout in which the bookmarks are displayed.",
|
||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
||||
defaultValue: "Vertical",
|
||||
},
|
||||
{
|
||||
name: "Hide title",
|
||||
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Bookmarks",
|
||||
description: "URLs and existing Apps shown by the widget.",
|
||||
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||
description: "List of bookmarks to display in the widget.",
|
||||
values: "Select one or more Apps directly, then order them as you like.",
|
||||
defaultValue: "No bookmarks selected",
|
||||
},
|
||||
{
|
||||
name: "Custom links",
|
||||
description: "URLs to display without first creating an App.",
|
||||
values: "List of URLs.",
|
||||
defaultValue: "No custom links",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -19,11 +19,35 @@
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 0.0625rem solid var(--mantine-color-default-border);
|
||||
outline-offset: -0.0625rem;
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
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,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconBulb } from "@tabler/icons-react";
|
||||
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
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 { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
@@ -33,12 +31,6 @@ import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||
<GridEditorRegistryProvider>
|
||||
<BoardSelectionProvider>{children}</BoardSelectionProvider>
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board.landmark");
|
||||
@@ -76,7 +68,7 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
@@ -152,7 +144,7 @@ export const ClientBoard = () => {
|
||||
</FloatingTip>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionGridProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { use, useCallback, useId, useState } from "react";
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const previewId = useId();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
@@ -59,7 +58,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
const originalState = state;
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
@@ -68,14 +66,11 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
onPreviewChange: setState,
|
||||
onPreviewRestore: () => setState(originalState),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
focusTargetId: previewId,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
@@ -91,7 +86,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -115,7 +110,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
|
||||
@@ -130,45 +130,6 @@
|
||||
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 {
|
||||
container-type: inline-size;
|
||||
overflow: auto;
|
||||
@@ -196,12 +157,30 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.newConversationButton {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelAction {
|
||||
width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
@@ -228,6 +207,21 @@
|
||||
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 {
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
@@ -283,7 +277,8 @@
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
.panelAction,
|
||||
.historyButton {
|
||||
.historyButton,
|
||||
.newConversationButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -306,6 +301,10 @@
|
||||
.composerContextLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panelThreadTitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 18rem) {
|
||||
|
||||
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
|
||||
<Box className={classes.empty}>
|
||||
<Stack align="center" gap="lg" maw={560} w="100%">
|
||||
<Stack align="center" gap="xs" maw={430}>
|
||||
<ThemeIcon size={52} radius="xl" variant="light">
|
||||
<ThemeIcon size={52} radius="xl" variant="light" color="red">
|
||||
<IconRobot size={27} />
|
||||
</ThemeIcon>
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -2957,6 +2957,7 @@ const Composer = (props: ComposerProps) => {
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
aria-label={
|
||||
@@ -3161,6 +3162,27 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
||||
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 = ({
|
||||
isRunning,
|
||||
pendingAction,
|
||||
@@ -3190,20 +3212,23 @@ export const AssistantConversationSurface = ({
|
||||
return (
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<AssistantPanelIdentity />
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
<AutoApprovalControl />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
<Button
|
||||
className={classes.newConversationButton}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={17} />}
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
|
||||
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
|
||||
const t = useI18n("assistant");
|
||||
return (
|
||||
<Group px="md" py="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="xl">
|
||||
<ThemeIcon color="red" variant="light" radius="xl">
|
||||
<IconRobot size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
|
||||
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
|
||||
<Box className={classes.widgetPanel}>
|
||||
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="xs" miw={0}>
|
||||
<ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
|
||||
<IconRobot size={19} />
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
@@ -167,7 +167,13 @@ interface AssistantWidgetStateProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const AssistantWidgetState = ({ icon: Icon, title, description, color, children }: AssistantWidgetStateProps) => (
|
||||
const AssistantWidgetState = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
color = "red",
|
||||
children,
|
||||
}: AssistantWidgetStateProps) => (
|
||||
<Center className={classes.widgetState} h="100%" p="md">
|
||||
<Stack align="center" gap="sm" ta="center" maw={360}>
|
||||
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.canvas {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 8rem;
|
||||
aspect-ratio: 16 / 7;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
@@ -84,3 +83,9 @@
|
||||
gap: rem(1px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
.canvas {
|
||||
min-height: 7rem;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,11 @@ import classes from "./board-layout-thumbnail.module.css";
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
@@ -27,7 +26,6 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -67,12 +65,12 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
||||
element.xOffset >= 0 &&
|
||||
element.xOffset < columnCount &&
|
||||
element.yOffset >= 0 &&
|
||||
element.yOffset < thumbnailRowLimit &&
|
||||
element.yOffset < maxThumbnailRows &&
|
||||
element.width > 0 &&
|
||||
element.height > 0,
|
||||
);
|
||||
const rowCount = Math.min(
|
||||
thumbnailRowLimit,
|
||||
maxThumbnailRows,
|
||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
.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);
|
||||
@@ -30,12 +11,6 @@
|
||||
.card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.metadataBadge {
|
||||
width: rem(22px);
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.option:hover .card,
|
||||
|
||||
@@ -3,31 +3,29 @@
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Kbd,
|
||||
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 { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } 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 { Link } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
|
||||
@@ -41,7 +39,6 @@ interface BoardSwitcherProps {
|
||||
|
||||
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("");
|
||||
@@ -53,27 +50,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
} = clientApi.board.getManageOverview.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const switcherBoards = useMemo(
|
||||
() => [
|
||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||
...boards.filter((board) => board.id === currentBoard?.id),
|
||||
],
|
||||
const availableBoards = useMemo(
|
||||
() => 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]);
|
||||
if (normalizedSearch.length === 0) return availableBoards;
|
||||
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||
}, [availableBoards, search]);
|
||||
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);
|
||||
@@ -83,7 +73,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
|
||||
|
||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||
|
||||
@@ -98,17 +88,14 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
}, [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") {
|
||||
if (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) {
|
||||
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
setSearch((current) => `${current}${event.key}`);
|
||||
setActiveIndex(0);
|
||||
@@ -154,7 +141,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
};
|
||||
|
||||
const results = getBoardSwitcherResults({
|
||||
availableBoardCount: switcherBoards.length,
|
||||
availableBoardCount: availableBoards.length,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
@@ -165,7 +152,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
manageBoardsT,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -175,40 +161,54 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
<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 }}
|
||||
title={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="md">
|
||||
<IconReplace size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>{t("title")}</Text>
|
||||
</Group>
|
||||
}
|
||||
size={1280}
|
||||
yOffset={32}
|
||||
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
|
||||
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||
classNames={{ content: classes.modalContent, body: classes.modalBody }}
|
||||
>
|
||||
<Stack
|
||||
data-autofocus
|
||||
tabIndex={-1}
|
||||
gap="xs"
|
||||
gap="md"
|
||||
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">
|
||||
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
|
||||
{results}
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
|
||||
{currentBoard && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("currentHidden", { name: currentBoard.name })}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<Kbd size="xs">
|
||||
<IconArrowRight size={12} />
|
||||
</Kbd>
|
||||
<Kbd size="xs">
|
||||
<IconArrowDown size={12} />
|
||||
</Kbd>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("keyboard.navigate")}
|
||||
</Text>
|
||||
<Kbd size="xs">Enter</Kbd>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("keyboard.open")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
@@ -227,7 +227,6 @@ interface BoardSwitcherResultsProps {
|
||||
setActiveIndex: (index: number) => void;
|
||||
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||
columnCount: number;
|
||||
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
|
||||
}
|
||||
|
||||
const getBoardSwitcherResults = ({
|
||||
@@ -242,7 +241,6 @@ const getBoardSwitcherResults = ({
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
manageBoardsT,
|
||||
}: BoardSwitcherResultsProps) => {
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -289,7 +287,7 @@ const getBoardSwitcherResults = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
|
||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
|
||||
{filteredBoards.map((board, index) => (
|
||||
<UnstyledButton
|
||||
key={board.id}
|
||||
@@ -298,7 +296,7 @@ const getBoardSwitcherResults = ({
|
||||
}}
|
||||
className={classes.option}
|
||||
w="100%"
|
||||
maw={240}
|
||||
maw={320}
|
||||
mx="auto"
|
||||
component={Link}
|
||||
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||
@@ -308,50 +306,21 @@ const getBoardSwitcherResults = ({
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onPointerEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<Card padding={0} withBorder radius="lg" className={classes.card}>
|
||||
<Card padding={0} withBorder radius="md" 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>
|
||||
<Card.Section withBorder p="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="md" size="md">
|
||||
<IconLayoutBoard size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={650} 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>
|
||||
|
||||
@@ -30,13 +30,6 @@ interface UpdateItemIntegrations {
|
||||
newIntegrations: string[];
|
||||
}
|
||||
|
||||
interface UpdateItemSettings {
|
||||
itemId: string;
|
||||
options: Record<string, unknown>;
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
integrationIds: string[];
|
||||
}
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -84,20 +77,6 @@ export const useItemActions = () => {
|
||||
[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(
|
||||
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
||||
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
||||
@@ -126,7 +105,6 @@ export const useItemActions = () => {
|
||||
updateItemOptions,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemSettings,
|
||||
duplicateItem,
|
||||
createItem,
|
||||
};
|
||||
|
||||
@@ -297,7 +297,6 @@ const LoadedBoardItemContent = ({
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getWidgetName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -34,7 +33,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
};
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useI18n("item");
|
||||
@@ -42,7 +40,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { integrations: integrationData, section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
|
||||
@@ -56,11 +55,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
const openEditModal = () => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
@@ -70,14 +64,19 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
onSuccessfulEdit: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrationIds,
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
onIntegrationSaved: resetErrorBoundary,
|
||||
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
@@ -58,29 +57,26 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
const tContainer = useI18n("section.container");
|
||||
const tMoveResize = useI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const registeredGridEditors = useRegisteredGridEditors();
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
{
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
},
|
||||
registeredGridEditors,
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
@@ -256,7 +252,6 @@ export const getMoveTargets = (
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
@@ -265,15 +260,13 @@ export const getMoveTargets = (
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
const registeredEditor = registeredGridEditors?.get(section.id);
|
||||
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -277,7 +277,6 @@ const ItemSelectModalContent = ({
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
@@ -347,7 +346,6 @@ const ItemSelectModalContent = ({
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||
|
||||
@@ -157,8 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
|
||||
const tCommon = useI18n("common.action");
|
||||
const settings = useSettings();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemSettings } = useItemActions();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
@@ -154,27 +154,19 @@ export const WidgetContextMenu = ({
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options,
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
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) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
@@ -197,8 +189,6 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -207,16 +197,16 @@ export const WidgetContextMenu = ({
|
||||
},
|
||||
);
|
||||
}, [
|
||||
board.id,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
options,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemSettings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemOptions,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- 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 `412 × 200`);
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||
- 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* individual cards never change their internal layout at different viewports.
|
||||
*/
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
export const LOGICAL_GRID_GAP = 12;
|
||||
export const LOGICAL_GRID_GAP = 24;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -23,8 +23,6 @@
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--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-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
|
||||
@@ -5,12 +5,7 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
|
||||
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 { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
getRootSectionForLane,
|
||||
} from "../index";
|
||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
test("gutters consume columns from the main canvas", () => {
|
||||
@@ -131,6 +126,6 @@ describe("board lanes", () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||
});
|
||||
});
|
||||
@@ -9,18 +9,18 @@ import {
|
||||
} from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
expect(getLogicalTrackSize(1)).toBe(200);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(94);
|
||||
expect(getLogicalTrackSize(2)).toBe(412);
|
||||
expect(getLogicalTrackSize(3)).toBe(624);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 212,
|
||||
top: 424,
|
||||
width: 412,
|
||||
height: 624,
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 648,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("fixed dashboard geometry", () => {
|
||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
||||
|
||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
|
||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Card
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
classes.containerCard,
|
||||
options.customCssClasses.join(" "),
|
||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||
)}
|
||||
@@ -57,7 +56,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--container-border-color": options.borderColor || undefined,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
@@ -67,7 +66,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
@@ -104,9 +103,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
<Badge
|
||||
className={classes.containerLabel}
|
||||
pos="absolute"
|
||||
top="calc(var(--mantine-spacing-xs) * -1)"
|
||||
top={-24}
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
@@ -115,7 +113,12 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
c="var(--mantine-color-text)"
|
||||
style={{
|
||||
zIndex: 9,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "var(--background-color)",
|
||||
borderColor: "var(--border-color)",
|
||||
}}
|
||||
title={options.title}
|
||||
data-board-container-label
|
||||
@@ -125,9 +128,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
className={classes.containerAction}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
top={isVisuallyCollapsed ? "50%" : -24}
|
||||
right={8}
|
||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||
variant={options.collapsible ? "subtle" : "default"}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { useRegisteredGridEditors } from "./grid-editor-registry";
|
||||
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
|
||||
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridEditorRegistryProvider>
|
||||
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
|
||||
{isEditMode && Provider && GridEditor && (
|
||||
<Provider>
|
||||
<RegisteredGridEditors GridEditor={GridEditor} />
|
||||
</Provider>
|
||||
)}
|
||||
</>
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,12 +23,7 @@ describe("dnd grid coordinates", () => {
|
||||
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
||||
const scale = 0.75;
|
||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||
const initialShape = {
|
||||
left: target.left,
|
||||
top: target.top,
|
||||
width: getLogicalTrackSize(2) * scale,
|
||||
height: 200 * scale,
|
||||
};
|
||||
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
|
||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||
|
||||
expect(
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface GridEditorRenderProps {
|
||||
}
|
||||
|
||||
export interface RegisteredGridEditor extends GridEditorRenderProps {
|
||||
placementMaxRowCount: number | null;
|
||||
host: HTMLElement;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||
position: absolute;
|
||||
inset: var(--board-grid-item-inset, 0px);
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
@@ -121,7 +121,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-placeholder {
|
||||
z-index: 0;
|
||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
@@ -140,7 +139,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-resize-outline {
|
||||
z-index: 1002;
|
||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
@@ -155,6 +153,17 @@ 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);
|
||||
}
|
||||
|
||||
[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-body]
|
||||
> [data-section-id]
|
||||
@@ -231,11 +240,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
top: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
@@ -249,11 +258,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
right: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
left: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
@@ -266,30 +275,42 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
/* 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. */
|
||||
clip-path: polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 4px 4px, 4px 40px, 40px 40px, 40px 4px, 4px 4px);
|
||||
clip-path: polygon(
|
||||
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) {
|
||||
|
||||
@@ -46,14 +46,12 @@
|
||||
|
||||
.contentMount {
|
||||
position: absolute;
|
||||
inset: var(--board-grid-item-inset, 0px);
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.staticItem:has([data-board-container-label]) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.editorEntry {
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
|
||||
@@ -130,10 +130,6 @@ export const SectionGrid = ({
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||
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
|
||||
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
@@ -179,7 +175,6 @@ export const SectionGrid = ({
|
||||
columnCount,
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placementMaxRowCount,
|
||||
placements: displayPlacements,
|
||||
transactionPlacements: placements,
|
||||
className: editorClassName,
|
||||
@@ -195,7 +190,6 @@ export const SectionGrid = ({
|
||||
entryElementStore,
|
||||
isInteractionDisabled,
|
||||
maxRowCount,
|
||||
placementMaxRowCount,
|
||||
placements,
|
||||
rowCount,
|
||||
section,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||
|
||||
describe("grid preview geometry", () => {
|
||||
@@ -27,12 +25,8 @@ describe("grid preview geometry", () => {
|
||||
|
||||
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
|
||||
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
|
||||
`${-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")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
|
||||
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
||||
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
||||
|
||||
@@ -63,10 +57,10 @@ describe("grid preview geometry", () => {
|
||||
|
||||
const active = elements.get("active");
|
||||
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
|
||||
expect(active?.dataset.gridW).toBe("3");
|
||||
expect(active?.dataset.gridH).toBe("4");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||
|
||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||
|
||||
describe("grid resize outline", () => {
|
||||
@@ -30,8 +28,8 @@ describe("grid resize outline", () => {
|
||||
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
||||
expect(outline?.style.left).toBe("0px");
|
||||
expect(outline?.style.top).toBe("0px");
|
||||
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
|
||||
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
|
||||
expect(outline?.style.width).toBe("200px");
|
||||
expect(outline?.style.height).toBe("424px");
|
||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||
|
||||
controller.schedule(null);
|
||||
|
||||
@@ -11,18 +11,6 @@
|
||||
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) {
|
||||
background-color: transparent !important;
|
||||
border-color: transparent !important;
|
||||
@@ -38,8 +26,8 @@
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.containerToggle :global(.mantine-Button-inner) {
|
||||
@@ -54,24 +42,6 @@
|
||||
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 {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
|
||||
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
|
||||
copySelectedItems: () => Promise<void>;
|
||||
pasteItems: () => Promise<void>;
|
||||
removeSelectedItems: () => void;
|
||||
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
|
||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
|
||||
|
||||
const moveSelectedItemsToSection = useCallback(
|
||||
(targetSectionId: string, maxRowCount?: number | null) => {
|
||||
(targetSectionId: string) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToMove = new Set(selectedItemIds);
|
||||
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
targetSection.kind === "container"
|
||||
? (containerLayout?.width ?? 0)
|
||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
|
||||
const rowCount = containerLayout?.height ?? 9999;
|
||||
if (columnCount === 0 || rowCount === 0) return previous;
|
||||
|
||||
const existingContainers = previous.sections
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
import { useBoardSelection } from "./board-selection-context";
|
||||
|
||||
const MAX_AVATARS_DISPLAYED = 5;
|
||||
@@ -41,7 +40,6 @@ export const BoardSelectionToolbar = () => {
|
||||
moveSelectedItemsToSection,
|
||||
} = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const registeredGridEditors = useRegisteredGridEditors();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const t = useI18n();
|
||||
@@ -98,15 +96,7 @@ export const BoardSelectionToolbar = () => {
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection
|
||||
? [
|
||||
{
|
||||
value: mainCanvasSection.id,
|
||||
label: t("item.create.destination.mainCanvas"),
|
||||
maxRowCount: null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
@@ -115,7 +105,6 @@ export const BoardSelectionToolbar = () => {
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
|
||||
},
|
||||
];
|
||||
}),
|
||||
@@ -124,7 +113,6 @@ export const BoardSelectionToolbar = () => {
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.options.title.trim() || t("section.container.untitled"),
|
||||
maxRowCount: section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.height,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -256,11 +244,7 @@ export const BoardSelectionToolbar = () => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||
{placementOptions.map((opt) => (
|
||||
<Menu.Item
|
||||
key={opt.value}
|
||||
disabled={opt.maxRowCount === undefined}
|
||||
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
|
||||
>
|
||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||
{opt.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
|
||||
|
Before Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 589 KiB |
|
Before Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 651 KiB |
@@ -86,7 +86,6 @@ interface BoardItemPlacementRectangle {
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -226,7 +225,7 @@ export const boardRouter = createTRPCRouter({
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
@@ -307,7 +306,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
@@ -332,7 +331,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
@@ -367,7 +366,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
|
||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||
});
|
||||
|
||||
test("returns compact previews by default and full previews on request", async () => {
|
||||
test("returns one compact base-layout preview for each accessible board", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const boardId = createId();
|
||||
@@ -487,10 +487,6 @@ describe("getManageOverview", () => {
|
||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
|
||||
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 () => {
|
||||
|
||||
@@ -514,9 +514,6 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
||||
|
||||
interface DemoWidget {
|
||||
kind: WidgetKind;
|
||||
section?: "right";
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
needsIntegration: boolean;
|
||||
@@ -565,9 +562,9 @@ const demoApps = [
|
||||
href: "https://portainer.io",
|
||||
},
|
||||
{
|
||||
name: "Uptime Kuma",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
|
||||
href: "https://uptime.kuma.pet",
|
||||
name: "Home Assistant",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
|
||||
href: "https://home-assistant.io",
|
||||
},
|
||||
{
|
||||
name: "Nextcloud",
|
||||
@@ -586,163 +583,69 @@ const demoApps = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
|
||||
// Daily focus
|
||||
{ kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
|
||||
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
||||
// Row 1: calendar + downloads + clock = 12
|
||||
{ kind: "calendar", width: 5, height: 3, 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: "weather",
|
||||
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,
|
||||
kind: "bookmarks",
|
||||
width: 6,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
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>`,
|
||||
},
|
||||
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
|
||||
},
|
||||
{ kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
|
||||
{ kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
|
||||
|
||||
// Operations center
|
||||
{ kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true },
|
||||
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
|
||||
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
|
||||
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
|
||||
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
|
||||
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
|
||||
{ kind: "notifications", width: 6, 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",
|
||||
xOffset: 0,
|
||||
yOffset: 9,
|
||||
width: 3,
|
||||
width: 6,
|
||||
height: 2,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
||||
maximumAmountPosts: 12,
|
||||
maximumAmountPosts: 20,
|
||||
textLinesClamp: 2,
|
||||
hideDescription: true,
|
||||
hideDescription: false,
|
||||
},
|
||||
},
|
||||
{ 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
|
||||
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
|
||||
...appIds.map(
|
||||
(appId, index): DemoWidget => ({
|
||||
(appId): DemoWidget => ({
|
||||
kind: "app",
|
||||
section: "right",
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: 1,
|
||||
width: 2,
|
||||
height: 1,
|
||||
needsIntegration: 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) => {
|
||||
@@ -807,100 +710,44 @@ 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();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "default",
|
||||
isPublic: false,
|
||||
creatorId: userId,
|
||||
pageTitle: "Homarr demo",
|
||||
backgroundImageUrl: "/images/demo-dashboard-background.svg",
|
||||
primaryColor: "#748FFC",
|
||||
secondaryColor: "#3BC9DB",
|
||||
opacity: 90,
|
||||
itemRadius: "xl",
|
||||
});
|
||||
|
||||
const mainSectionId = createId();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: mainSectionId,
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const rightSectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: rightSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const layoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 13,
|
||||
rightGutterColumnCount: 1,
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
|
||||
const demoWidgets = buildDemoWidgets(appIds);
|
||||
let xOffset = 0;
|
||||
let yOffset = 0;
|
||||
let rowMaxHeight = 0;
|
||||
for (const widget of demoWidgets) {
|
||||
let sectionId = mainSectionId;
|
||||
if (widget.section === "right") {
|
||||
sectionId = rightSectionId;
|
||||
if (xOffset + widget.width > 12) {
|
||||
xOffset = 0;
|
||||
yOffset += rowMaxHeight;
|
||||
rowMaxHeight = 0;
|
||||
}
|
||||
|
||||
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
@@ -912,8 +759,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: widget.xOffset,
|
||||
yOffset: widget.yOffset,
|
||||
xOffset,
|
||||
yOffset,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
});
|
||||
@@ -923,6 +770,7 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
xOffset += widget.width;
|
||||
}
|
||||
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||
|
||||
@@ -45,169 +45,6 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
|
||||
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", () => {
|
||||
const connection = new BetterSqlite3(":memory:");
|
||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||
|
||||
@@ -161,7 +161,7 @@ export interface DefaultWidgetConfig {
|
||||
|
||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||
{ kind: "clock", width: 2, height: 1 },
|
||||
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
|
||||
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
|
||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||
];
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
.inspectorContent {
|
||||
--modal-content-flex: 0 0 100%;
|
||||
--modal-content-max-height: min(88dvh, 52rem);
|
||||
--modal-size: 100%;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||
}
|
||||
|
||||
@@ -25,13 +23,8 @@
|
||||
--modal-content-flex: 0 0 min(34rem, 46vw);
|
||||
--modal-content-height: 100dvh;
|
||||
--modal-content-max-height: 100dvh;
|
||||
--modal-size: min(34rem, 46vw);
|
||||
|
||||
width: min(34rem, 46vw);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.inspectorInner:has([data-widget-inspector-side="left"]) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ export type CreateModalOptions = Pick<
|
||||
| "centered"
|
||||
| "keepMounted"
|
||||
| "withCloseButton"
|
||||
| "withOverlay"
|
||||
| "zIndex"
|
||||
| "scrollAreaComponent"
|
||||
| "yOffset"
|
||||
|
||||
@@ -2192,51 +2192,8 @@
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"adaptive": {
|
||||
"label": "Adaptive"
|
||||
},
|
||||
"gridHorizontal": {
|
||||
"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"
|
||||
"label": "Grid horizontal"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2253,21 +2210,17 @@
|
||||
"label": "Show border"
|
||||
},
|
||||
"customUrls": {
|
||||
"label": "Custom links"
|
||||
"label": "Custom links",
|
||||
"description": "Add URLs directly without creating an App first"
|
||||
},
|
||||
"items": {
|
||||
"label": "Bookmarks",
|
||||
"remove": "Remove bookmark",
|
||||
"apps": {
|
||||
"label": "Add bookmarks",
|
||||
"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."
|
||||
"label": "Apps",
|
||||
"placeholder": "Select one or more apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty": "Add a URL or App in the widget settings"
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "ArchiveTeam Warrior",
|
||||
@@ -3059,10 +3012,12 @@
|
||||
"label": "Animate weather icons"
|
||||
},
|
||||
"showHumidity": {
|
||||
"label": "Show humidity"
|
||||
"label": "Show humidity",
|
||||
"description": "Display humidity in current conditions, hourly details and daily forecasts"
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Show current wind speed"
|
||||
"label": "Show current wind speed",
|
||||
"description": "Only on current weather"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "Use mph for windspeed"
|
||||
|
||||
@@ -25,7 +25,6 @@ export const theme = createTheme({
|
||||
},
|
||||
|
||||
fontSizes: {
|
||||
xxs: rem(11),
|
||||
xs: rem(12),
|
||||
sm: rem(14),
|
||||
md: rem(16),
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
boardId?: string;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
||||
|
||||
/**
|
||||
* Short description why as and unknown convertions are used below:
|
||||
|
||||
@@ -96,28 +96,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
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(
|
||||
(value: TOptionValue) => {
|
||||
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
||||
@@ -134,13 +112,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack>
|
||||
<options.addButton
|
||||
addItem={addItem}
|
||||
migrateItems={migrateItems}
|
||||
removeItem={removeItem}
|
||||
values={values}
|
||||
initialOptions={initialOptions}
|
||||
/>
|
||||
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -181,7 +153,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
||||
index={index}
|
||||
item={item}
|
||||
removeItem={() => removeItem(value)}
|
||||
removeLabel={t("remove")}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
@@ -206,7 +177,6 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
|
||||
item: TItem;
|
||||
index: number;
|
||||
removeItem: () => void;
|
||||
removeLabel: string;
|
||||
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
||||
}
|
||||
|
||||
@@ -215,7 +185,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
index,
|
||||
item,
|
||||
removeItem,
|
||||
removeLabel,
|
||||
options,
|
||||
}: ItemProps<TItem, TOptionValue>) => {
|
||||
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||
@@ -251,7 +220,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
key={index}
|
||||
item={item}
|
||||
removeItem={removeItem}
|
||||
removeLabel={removeLabel}
|
||||
rootAttributes={attributes}
|
||||
handle={
|
||||
<ActionIcon
|
||||
|
||||
@@ -82,7 +82,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Group>
|
||||
|
||||
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
|
||||
<Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
|
||||
{status.broadcastMessage}
|
||||
</Text>
|
||||
@@ -143,13 +143,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
{status.items.map((item) => {
|
||||
const itemStatusKey = getStatusKey(item.status);
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
@@ -207,9 +201,6 @@ export const getArchiveCompactLayout = (width: number, height: number, isAdvance
|
||||
|
||||
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";
|
||||
|
||||
const getStatusKey = (status?: string | null): WarriorStatusKey => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap="xs" ta="center">
|
||||
<ThemeIcon variant="light" radius="xl" size="lg">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
||||
<IconRobot size="var(--mantine-font-size-xl)" />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
gap: clamp(2px, 1.5cqi, 4px);
|
||||
padding: clamp(4px, 2cqi, 8px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: transparent;
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: transparent;
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
}}
|
||||
>
|
||||
<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
|
||||
gap={dotGap}
|
||||
wrap="nowrap"
|
||||
@@ -105,12 +101,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={value}
|
||||
color={thresholdColor(value)}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
@@ -120,11 +111,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
) : (
|
||||
<Box style={{ display: "flex", alignItems: "center", 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>
|
||||
)}
|
||||
|
||||
@@ -1,198 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
Group,
|
||||
Loader,
|
||||
PillsInput,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
|
||||
import type { MultiSelectProps } from "@mantine/core";
|
||||
import { Group, Loader, MultiSelect } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { SortableItemListInput } from "../options";
|
||||
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||
import type { BookmarkItem } from "./bookmark-item";
|
||||
|
||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
|
||||
|
||||
const createOptionValue = "$create-url";
|
||||
|
||||
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
|
||||
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
|
||||
addItem,
|
||||
migrateItems,
|
||||
removeItem,
|
||||
values,
|
||||
initialOptions,
|
||||
}) => {
|
||||
const t = useI18n("widget.bookmarks.option.items.apps");
|
||||
const tCommon = useI18n("common");
|
||||
const tSelect = useI18n("app.action.select");
|
||||
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
||||
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 appsById = new Map(apps.map((app) => [app.id, app]));
|
||||
|
||||
useEffect(() => {
|
||||
if (migrationStartedRef.current || legacyUrls.length === 0) return;
|
||||
|
||||
migrationStartedRef.current = true;
|
||||
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
|
||||
migrateItems(legacyItems, { customUrls: [] });
|
||||
}, [legacyUrls, migrateItems]);
|
||||
|
||||
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 handleChange = (nextValues: string[]) => {
|
||||
const nextIds = new Set(nextValues);
|
||||
for (const value of values) {
|
||||
if (!nextIds.has(value)) removeItem(value);
|
||||
}
|
||||
|
||||
const app = apps.find((candidate) => candidate.id === value);
|
||||
if (app) addItem(app);
|
||||
setSearch("");
|
||||
const currentIds = new Set(values);
|
||||
for (const value of nextValues) {
|
||||
if (currentIds.has(value)) continue;
|
||||
const app = appsById.get(value);
|
||||
if (app) addItem(app);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={handleOptionSubmit}
|
||||
position="bottom-start"
|
||||
middlewares={{ flip: true, shift: true }}
|
||||
styles={{
|
||||
dropdown: { overflow: "hidden" },
|
||||
option: { borderRadius: "var(--mantine-radius-sm)" },
|
||||
}}
|
||||
>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
label={t("label")}
|
||||
description={t("description")}
|
||||
error={error ? tCommon("error") : undefined}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
leftSection={
|
||||
<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>
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
placeholder={t("placeholder")}
|
||||
value={values}
|
||||
onChange={handleChange}
|
||||
searchable
|
||||
clearable
|
||||
clearSearchOnChange
|
||||
hidePickedOptions
|
||||
disabled={isPending || Boolean(error)}
|
||||
error={error ? tCommon("error") : undefined}
|
||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
nothingFoundMessage={tSelect("notFound")}
|
||||
renderOption={renderAppOption}
|
||||
data={apps.map((app) => ({
|
||||
value: app.id,
|
||||
label: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -1,66 +0,0 @@
|
||||
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];
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
"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,3 +1,525 @@
|
||||
"use client";
|
||||
|
||||
export { default } from "./bookmarks-widget";
|
||||
import type { CSSProperties } from "react";
|
||||
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,13 +1,14 @@
|
||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
|
||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { getSafeAppHref } from "../common/application-url";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { BookmarkAddButton } from "./add-button";
|
||||
import type { BookmarkSelectionItem } from "./add-button";
|
||||
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconBookmark,
|
||||
@@ -17,63 +18,42 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
layout: factory.select({
|
||||
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
|
||||
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
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 === "row") return t("widget.common.layout.option.horizontal");
|
||||
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");
|
||||
},
|
||||
})),
|
||||
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",
|
||||
defaultValue: "column",
|
||||
}),
|
||||
hideTitle: factory.switch({ defaultValue: false }),
|
||||
hideIcon: factory.switch({ defaultValue: false }),
|
||||
hideHostname: factory.switch({ defaultValue: false }),
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
withBorder: factory.switch({ defaultValue: false }),
|
||||
customUrls: factory.internal({ defaultValue: [] as string[] }),
|
||||
items: factory.sortableItemList<BookmarkSelectionItem, string>({
|
||||
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
|
||||
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
|
||||
customUrls: factory.multiText({
|
||||
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
|
||||
withDescription: true,
|
||||
}),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
|
||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap" miw={0}>
|
||||
<Group wrap="nowrap">
|
||||
{handle}
|
||||
|
||||
<Group wrap="nowrap" miw={0}>
|
||||
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
|
||||
<IconLink size={20} />
|
||||
</Avatar>
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={600} truncate>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{item.href}
|
||||
</Text>
|
||||
<Group>
|
||||
<Avatar src={item.iconUrl} alt={item.name} />
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
|
||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
||||
<IconX size="var(--mantine-font-size-xl)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
@@ -82,14 +62,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
AddButton: BookmarkAddButton,
|
||||
uniqueIdentifier: (item) => item.id,
|
||||
useData: (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) ?? [];
|
||||
});
|
||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -1,69 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||
import {
|
||||
createCustomBookmark,
|
||||
getAdvancedBookmarkColumns,
|
||||
getBookmarkHostname,
|
||||
getCompactBookmarkLayout,
|
||||
} from "./component";
|
||||
|
||||
describe("bookmark display helpers", () => {
|
||||
test("uses explicit width breakpoints for advanced columns", () => {
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
columns: 3,
|
||||
orientation: "horizontal",
|
||||
});
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
columns: 1,
|
||||
orientation: "horizontal",
|
||||
});
|
||||
test("uses bounded advanced columns", () => {
|
||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test("selects an adaptive plan from width and height breakpoints", () => {
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
test("uses container geometry to keep compact cards legible", () => {
|
||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
||||
columns: 2,
|
||||
itemHeight: 128,
|
||||
orientation: "horizontal",
|
||||
showHostname: true,
|
||||
showTitle: true,
|
||||
hideHostname: false,
|
||||
hideTitle: false,
|
||||
minimumItemSize: 48,
|
||||
});
|
||||
expect(
|
||||
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
|
||||
).toMatchObject({
|
||||
columns: 1,
|
||||
orientation: "icon",
|
||||
showHostname: false,
|
||||
showTitle: false,
|
||||
|
||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
||||
columns: 4,
|
||||
hideHostname: true,
|
||||
hideTitle: true,
|
||||
minimumItemSize: 96,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps compact cards visible when icons are hidden", () => {
|
||||
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
|
||||
expect(
|
||||
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
|
||||
).toEqual({
|
||||
orientation: "horizontal",
|
||||
showHostname: false,
|
||||
showIcon: false,
|
||||
showTitle: true,
|
||||
});
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates standalone bookmarks without app records", () => {
|
||||
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
|
||||
id: "url:https://homarr.dev/docs",
|
||||
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
|
||||
id: "custom-link:https://homarr.dev/docs",
|
||||
name: "homarr.dev",
|
||||
description: null,
|
||||
href: "https://homarr.dev/docs",
|
||||
});
|
||||
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
|
||||
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("splits URL lists without corrupting commas inside a URL", () => {
|
||||
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
|
||||
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
|
||||
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
|
||||
id: "custom-link:https://homarr.dev/docs",
|
||||
name: "homarr.dev",
|
||||
description: null,
|
||||
href: "https://homarr.dev/docs",
|
||||
});
|
||||
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
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,12 +4,11 @@ import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
import { getDirectBookmarkUrl } from "./bookmark-item";
|
||||
|
||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||
|
||||
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
|
||||
const appIds = items.flatMap((item) => item.options.items);
|
||||
const distinctAppIds = [...new Set(appIds)];
|
||||
|
||||
const dbApps = await db.query.apps.findMany({
|
||||
@@ -17,14 +16,13 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
|
||||
if (itemAppIds.length === 0) {
|
||||
if (item.options.items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
createTrpcQueryKey("app.byIds", itemAppIds),
|
||||
dbApps.filter((app) => itemAppIds.includes(app.id)),
|
||||
createTrpcQueryKey("app.byIds", item.options.items),
|
||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,3 @@
|
||||
display: flex;
|
||||
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,10 +180,14 @@ const CalendarBase = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
|
||||
|
||||
return (
|
||||
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" 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
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||
@@ -256,13 +260,7 @@ const CalendarBase = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{hasErrors && (
|
||||
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -135,13 +135,14 @@ export function HomarrDataTable<T>({
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
loaderBackgroundBlur={2}
|
||||
textSelectionDisabled
|
||||
height="100%"
|
||||
{...props}
|
||||
striped={false}
|
||||
columns={fittedColumns}
|
||||
onMouseDownCapture={handleMouseDownCapture}
|
||||
className={mergedClassName}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
.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,7 +8,6 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import classes from "./component.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
@@ -72,12 +71,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card p={0} radius="sm" bg="transparent">
|
||||
<Card p={0} radius="sm">
|
||||
<Group
|
||||
p="xs"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
className={classes.neutralDividerBottom}
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
@@ -136,7 +135,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
className={classes.accordion}
|
||||
variant="filled"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
@@ -177,7 +175,11 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
|
||||
const t = useI18n("widget.coolify");
|
||||
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
||||
return (
|
||||
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
|
||||
<Group
|
||||
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">
|
||||
v{version}
|
||||
</Text>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import classes from "./component.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
@@ -83,7 +82,6 @@ export function SingleInstanceLayout({
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
className={classes.accordion}
|
||||
variant="contained"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
@@ -125,7 +123,11 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
|
||||
const t = useI18n("widget.coolify");
|
||||
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
||||
return (
|
||||
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.card {
|
||||
background-color: transparent;
|
||||
[data-mantine-color-scheme="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
@@ -195,9 +195,6 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const label = translateIfNecessary(t, item.label);
|
||||
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 (
|
||||
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
|
||||
@@ -209,11 +206,9 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
className="summary-card"
|
||||
p="sm"
|
||||
radius={board.itemRadius}
|
||||
bg={backgroundColor}
|
||||
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
|
||||
style={{
|
||||
flex: 1,
|
||||
border:
|
||||
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -148,9 +146,8 @@ const createColumns = (
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{inlineState && <ContainerStateDot state={container.state} />}
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
size={20}
|
||||
radius="xl"
|
||||
size={24}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
style={{ flexShrink: 0 }}
|
||||
@@ -435,7 +432,6 @@ export default function DockerWidget({
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding="2px 8px"
|
||||
className="docker-table"
|
||||
rowCursor="default"
|
||||
fetching={isFetching && containers.length === 0}
|
||||
fz={width < 400 ? "xs" : "sm"}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/* 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,6 +143,11 @@ const stateColorMap: Record<DownloadState, string> = {
|
||||
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>> = {
|
||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||
ratio: (ctx) => ctx.hasTorrents,
|
||||
@@ -601,7 +606,7 @@ export default function DownloadClientsWidget({
|
||||
title: t("items.state.columnTitle"),
|
||||
width: 90,
|
||||
render: (record) => (
|
||||
<Badge size="xs" variant="dot" color={stateColorMap[record.state]}>
|
||||
<Badge size="xs" variant="light" color={stateColorMap[record.state]}>
|
||||
{t(`states.${record.state}`)}
|
||||
</Badge>
|
||||
),
|
||||
@@ -779,6 +784,7 @@ export default function DownloadClientsWidget({
|
||||
className="downloads-table"
|
||||
customRowAttributes={(_, index) => ({ "data-row-index": index })}
|
||||
onRowContextMenu={rowContextMenuHandler}
|
||||
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
|
||||
rowExpansion={{
|
||||
trigger: "click",
|
||||
allowMultiple: true,
|
||||
@@ -894,7 +900,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="dot" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
||||
{t(`states.${item.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
@@ -27,7 +27,6 @@ import type { WidgetComponentProps } from "../definition";
|
||||
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
|
||||
import { FirewallMenu } from "./firewall-menu";
|
||||
import { FirewallVersion, formatVersion } from "./firewall-version";
|
||||
import classes from "./firewall.module.css";
|
||||
|
||||
export interface Firewall {
|
||||
label: string;
|
||||
@@ -244,7 +243,7 @@ const FirewallPanel = ({
|
||||
t,
|
||||
tCommon,
|
||||
}: FirewallPanelProps) => (
|
||||
<Paper className={classes.card} withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0} bg="transparent">
|
||||
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
|
||||
<Stack gap="xs">
|
||||
{isAdvanced && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
@@ -383,7 +382,6 @@ const InterfacesPanel = ({
|
||||
value={isAdvanced ? "interfaces" : accordionValue}
|
||||
onChange={isAdvanced ? undefined : setAccordionValue}
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
classNames={{ item: classes.accordionItem, control: classes.accordionControl }}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Box, Select } from "@mantine/core";
|
||||
|
||||
import type { Firewall } from "./component";
|
||||
import classes from "./firewall.module.css";
|
||||
|
||||
interface FirewallMenuProps {
|
||||
onChange: (value: string | null) => void;
|
||||
@@ -19,7 +18,6 @@ export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall
|
||||
w="100%"
|
||||
color="lightgray"
|
||||
data={dropdownItems}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: "24px",
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
.card {
|
||||
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="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
.accordionItem {
|
||||
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);
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
.card {
|
||||
background-color: transparent;
|
||||
[data-mantine-color-scheme="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
|
||||
.systemPanel {
|
||||
|
||||
@@ -191,7 +191,7 @@ export const SystemHealthMonitoring = ({
|
||||
))}
|
||||
</Flex>
|
||||
{isAdvanced && (
|
||||
<Card className={classes.card} radius={board.itemRadius} p="sm">
|
||||
<Card radius={board.itemRadius} p="sm">
|
||||
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
.statItem {
|
||||
padding: 8px 10px;
|
||||
border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: transparent;
|
||||
padding: 12px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.statItemDense {
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 3px 6px;
|
||||
border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.32));
|
||||
min-height: 44px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.statItem,
|
||||
.statItemDense {
|
||||
border-color: CanvasText;
|
||||
}
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
@@ -55,49 +55,42 @@ export default function ImmichServerStatsWidget({
|
||||
Number(statVisibility.showStorage);
|
||||
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
|
||||
|
||||
const statItems = [
|
||||
{
|
||||
key: "users",
|
||||
enabled: statVisibility.showUsers,
|
||||
icon: <IconUsers size={statsLayout.dense ? 16 : 18} />,
|
||||
label: t("users"),
|
||||
value: stats.userCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "photos",
|
||||
enabled: statVisibility.showPhotos,
|
||||
icon: <IconPhoto size={statsLayout.dense ? 16 : 18} />,
|
||||
label: t("photos"),
|
||||
value: stats.photoCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "videos",
|
||||
enabled: statVisibility.showVideos,
|
||||
icon: <IconVideo size={statsLayout.dense ? 16 : 18} />,
|
||||
label: t("videos"),
|
||||
value: stats.videoCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
enabled: statVisibility.showStorage,
|
||||
icon: <IconDatabase size={statsLayout.dense ? 16 : 18} />,
|
||||
label: t("storage"),
|
||||
value: formatBytes(stats.totalLibraryUsageInBytes),
|
||||
},
|
||||
]
|
||||
.filter((item) => item.enabled)
|
||||
.map(({ key, icon, label, value }) => (
|
||||
<StatItem key={key} icon={icon} label={label} value={value} dense={statsLayout.dense} />
|
||||
));
|
||||
|
||||
const statsContent =
|
||||
isAdvanced || statsLayout.dense ? (
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{statItems}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack gap="xs">{statItems}</Stack>
|
||||
);
|
||||
const statsContent = (
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{statVisibility.showUsers && (
|
||||
<StatItem
|
||||
icon={<IconUsers size="var(--mantine-font-size-xl)" />}
|
||||
label={t("users")}
|
||||
value={stats.userCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{statVisibility.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size="var(--mantine-font-size-xl)" />}
|
||||
label={t("photos")}
|
||||
value={stats.photoCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{statVisibility.showVideos && (
|
||||
<StatItem
|
||||
icon={<IconVideo size="var(--mantine-font-size-xl)" />}
|
||||
label={t("videos")}
|
||||
value={stats.videoCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{statVisibility.showStorage && (
|
||||
<StatItem
|
||||
icon={<IconDatabase size="var(--mantine-font-size-xl)" />}
|
||||
label={t("storage")}
|
||||
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
|
||||