Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b21d0881 | ||
|
|
19760e5722 | ||
|
|
2570de25a6 | ||
|
|
f46ccadbe4 | ||
|
|
6297ca4b16 | ||
|
|
2accdc2fad | ||
|
|
8a08293016 |
No files matched your search
@@ -132,8 +132,8 @@ Here you have the following options in the header:
|
||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||
- **Board switcher**: Use this button to open another board that you can access.
|
||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
||||
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
|
||||
it directly. The current board is left out of the results.
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
@@ -369,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
|
||||
|
||||
### Board tour
|
||||
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
|
||||
|
||||
### Tour controls
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ 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 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
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
|
||||
<title id="title">Abstract homelab network</title>
|
||||
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
|
||||
<defs>
|
||||
<radialGradient id="base" cx="50%" cy="40%" r="80%">
|
||||
<stop offset="0" stop-color="#172047"/>
|
||||
<stop offset="0.48" stop-color="#0b1028"/>
|
||||
<stop offset="1" stop-color="#060814"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="indigoGlow">
|
||||
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
|
||||
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="cyanGlow">
|
||||
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop stop-color="#748ffc" stop-opacity="0.05"/>
|
||||
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
|
||||
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur">
|
||||
<feGaussianBlur stdDeviation="55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1920" height="1080" fill="url(#base)"/>
|
||||
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
|
||||
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
|
||||
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
|
||||
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
|
||||
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
|
||||
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
|
||||
</g>
|
||||
<g fill="#9aa8ff" opacity=".48">
|
||||
<circle cx="248" cy="660" r="3"/>
|
||||
<circle cx="718" cy="511" r="4"/>
|
||||
<circle cx="1118" cy="287" r="3"/>
|
||||
<circle cx="1450" cy="110" r="4"/>
|
||||
</g>
|
||||
<g fill="#5ee0ee" opacity=".42">
|
||||
<circle cx="435" cy="850" r="3"/>
|
||||
<circle cx="908" cy="604" r="4"/>
|
||||
<circle cx="1350" cy="510" r="3"/>
|
||||
<circle cx="1715" cy="285" r="4"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
|
||||
<circle cx="330" cy="90" r="260"/>
|
||||
<circle cx="330" cy="90" r="345"/>
|
||||
<circle cx="1680" cy="940" r="250"/>
|
||||
<circle cx="1680" cy="940" r="335"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -19,35 +19,11 @@
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
outline: 0.0625rem solid var(--mantine-color-default-border);
|
||||
outline-offset: -0.0625rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"]::before {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
height: 24px;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
margin-top: -24px;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
line-height: 24px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
content: attr(aria-label);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconBulb } from "@tabler/icons-react";
|
||||
|
||||
@@ -22,6 +23,7 @@ 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";
|
||||
@@ -31,6 +33,12 @@ 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");
|
||||
@@ -68,7 +76,7 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardSelectionProvider>
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
@@ -144,7 +152,7 @@ export const ClientBoard = () => {
|
||||
</FloatingTip>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionProvider>
|
||||
</BoardSelectionGridProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -3,18 +3,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -25,8 +16,6 @@ import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
@@ -56,9 +45,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
if (!hasChangeAccess) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,8 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -235,47 +220,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n("board");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const tBoard = useI18n("board");
|
||||
|
||||
@@ -130,6 +130,45 @@
|
||||
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;
|
||||
@@ -157,30 +196,12 @@
|
||||
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;
|
||||
@@ -207,21 +228,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -277,8 +283,7 @@
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
.panelAction,
|
||||
.historyButton,
|
||||
.newConversationButton {
|
||||
.historyButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -301,10 +306,6 @@
|
||||
.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" color="red">
|
||||
<ThemeIcon size={52} radius="xl" variant="light">
|
||||
<IconRobot size={27} />
|
||||
</ThemeIcon>
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -2957,7 +2957,6 @@ const Composer = (props: ComposerProps) => {
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
aria-label={
|
||||
@@ -3162,27 +3161,6 @@ 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,
|
||||
@@ -3212,23 +3190,20 @@ 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>
|
||||
<Button
|
||||
className={classes.newConversationButton}
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={17} />}
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
|
||||
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
|
||||
const t = useI18n("assistant");
|
||||
return (
|
||||
<Group px="md" py="sm" wrap="nowrap">
|
||||
<ThemeIcon color="red" variant="light" radius="xl">
|
||||
<ThemeIcon 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" color="red" radius="xl" size="lg" flex="0 0 auto">
|
||||
<ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
|
||||
<IconRobot size={19} />
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
@@ -167,13 +167,7 @@ interface AssistantWidgetStateProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const AssistantWidgetState = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
color = "red",
|
||||
children,
|
||||
}: AssistantWidgetStateProps) => (
|
||||
const AssistantWidgetState = ({ icon: Icon, title, description, color, 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">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.content {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option:hover .card,
|
||||
.option:focus-visible .card,
|
||||
.option[data-active="true"] .card {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Kbd,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
useMatches,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
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 } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
|
||||
import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
|
||||
interface BoardSwitcherProps {
|
||||
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
||||
}
|
||||
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
const t = useI18n("board.action.switcher");
|
||||
const currentBoard = useOptionalBoard();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const responsiveColumnCount = useMatches({ base: 1, sm: 2, lg: 3, xl: 4 });
|
||||
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
|
||||
const utils = clientApi.useUtils();
|
||||
const {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
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 availableBoards;
|
||||
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||
}, [availableBoards, search]);
|
||||
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||
|
||||
const openSwitcher = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
const closeSwitcher = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
|
||||
|
||||
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || filteredBoards.length === 0) return;
|
||||
const normalizedIndex = Math.min(activeIndex, filteredBoards.length - 1);
|
||||
if (normalizedIndex !== activeIndex) {
|
||||
setActiveIndex(normalizedIndex);
|
||||
return;
|
||||
}
|
||||
optionRefs.current[normalizedIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [activeIndex, filteredBoards.length, isOpen]);
|
||||
|
||||
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
setSearch((current) => current.slice(0, -1));
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
setSearch((current) => `${current}${event.key}`);
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredBoards.length === 0) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest("[data-board-switcher-option]")) return;
|
||||
event.preventDefault();
|
||||
optionRefs.current[activeIndex]?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex = activeIndex;
|
||||
if (event.key === "ArrowRight") {
|
||||
nextIndex = (activeIndex + 1) % filteredBoards.length;
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
nextIndex = (activeIndex - 1 + filteredBoards.length) % filteredBoards.length;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const nextRowIndex = activeIndex + columnCount;
|
||||
nextIndex = nextRowIndex;
|
||||
if (nextRowIndex >= filteredBoards.length) nextIndex = activeIndex % columnCount;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
const previousRowIndex = activeIndex - columnCount;
|
||||
if (previousRowIndex >= 0) {
|
||||
nextIndex = previousRowIndex;
|
||||
} else {
|
||||
const lastRowStart = Math.floor((filteredBoards.length - 1) / columnCount) * columnCount;
|
||||
const lastIndexInColumn = lastRowStart + activeIndex;
|
||||
nextIndex = lastIndexInColumn;
|
||||
if (lastIndexInColumn >= filteredBoards.length) nextIndex -= columnCount;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setActiveIndex(nextIndex);
|
||||
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus({ preventScroll: true }));
|
||||
};
|
||||
|
||||
const results = getBoardSwitcherResults({
|
||||
availableBoardCount: availableBoards.length,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
search,
|
||||
t,
|
||||
activeIndex,
|
||||
closeSwitcher,
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
|
||||
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={closeSwitcher}
|
||||
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" }}
|
||||
>
|
||||
<Stack
|
||||
data-autofocus
|
||||
tabIndex={-1}
|
||||
gap="md"
|
||||
onKeyDown={handleModalKeyDown}
|
||||
className={classes.content}
|
||||
aria-describedby="board-switcher-instructions"
|
||||
>
|
||||
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoardSwitcherResultsProps {
|
||||
availableBoardCount: number;
|
||||
filteredBoards: RouterOutputs["board"]["getManageOverview"];
|
||||
isError: boolean;
|
||||
isPending: boolean;
|
||||
search: string;
|
||||
t: ReturnType<typeof useI18n<"board.action.switcher">>;
|
||||
activeIndex: number;
|
||||
closeSwitcher: () => void;
|
||||
setActiveIndex: (index: number) => void;
|
||||
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
const getBoardSwitcherResults = ({
|
||||
availableBoardCount,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
search,
|
||||
t,
|
||||
activeIndex,
|
||||
closeSwitcher,
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
}: BoardSwitcherResultsProps) => {
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("error")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (availableBoardCount === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap={4}>
|
||||
<ThemeIcon variant="light" color="gray" size="xl" radius="xl">
|
||||
<IconLayoutBoard stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{t("empty.title")}</Text>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("empty.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredBoards.length === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("search.empty", { search })}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
|
||||
{filteredBoards.map((board, index) => (
|
||||
<UnstyledButton
|
||||
key={board.id}
|
||||
ref={(node) => {
|
||||
optionRefs.current[index] = node;
|
||||
}}
|
||||
className={classes.option}
|
||||
w="100%"
|
||||
maw={320}
|
||||
mx="auto"
|
||||
component={Link}
|
||||
href={`/boards/${encodeURIComponent(board.name)}`}
|
||||
data-active={index === activeIndex}
|
||||
data-board-switcher-option
|
||||
onClick={closeSwitcher}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onPointerEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<Card padding={0} withBorder radius="md" className={classes.card}>
|
||||
<Card.Section>
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
/>
|
||||
</Card.Section>
|
||||
<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>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -57,26 +58,29 @@ 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 = 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 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 targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
@@ -252,6 +256,7 @@ export const getMoveTargets = (
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
@@ -260,13 +265,15 @@ 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: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- 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 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 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 = 24;
|
||||
export const LOGICAL_GRID_GAP = 12;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -23,6 +23,8 @@
|
||||
* 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,7 +5,12 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { 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, getRootSectionForLane } from "../index";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
getRootSectionForLane,
|
||||
} from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
test("gutters consume columns from the main canvas", () => {
|
||||
@@ -126,6 +131,6 @@ describe("board lanes", () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
|
||||
});
|
||||
});
|
||||
@@ -9,18 +9,18 @@ import {
|
||||
} from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
|
||||
expect(getLogicalTrackSize(1)).toBe(200);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(94);
|
||||
expect(getLogicalTrackSize(2)).toBe(412);
|
||||
expect(getLogicalTrackSize(3)).toBe(624);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 648,
|
||||
left: 212,
|
||||
top: 424,
|
||||
width: 412,
|
||||
height: 624,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(11);
|
||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Card
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
classes.containerCard,
|
||||
options.customCssClasses.join(" "),
|
||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||
)}
|
||||
@@ -56,7 +57,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
"--container-border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
@@ -66,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
@@ -103,8 +104,9 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
<Badge
|
||||
className={classes.containerLabel}
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
top="calc(var(--mantine-spacing-xs) * -1)"
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
@@ -113,12 +115,7 @@ 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
|
||||
@@ -128,8 +125,9 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
className={classes.containerAction}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? "50%" : -24}
|
||||
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
|
||||
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 { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
|
||||
import { 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,7 +23,12 @@ 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: 424 * scale, height: 200 * scale };
|
||||
const initialShape = {
|
||||
left: target.left,
|
||||
top: target.top,
|
||||
width: getLogicalTrackSize(2) * scale,
|
||||
height: 200 * scale,
|
||||
};
|
||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||
|
||||
expect(
|
||||
|
||||
@@ -55,6 +55,7 @@ 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: 0;
|
||||
inset: var(--board-grid-item-inset, 0px);
|
||||
z-index: 100;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
@@ -121,6 +121,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-placeholder {
|
||||
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);
|
||||
@@ -139,6 +140,7 @@ 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);
|
||||
@@ -153,17 +155,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
[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]
|
||||
@@ -240,11 +231,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: -0.25rem;
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: -0.25rem;
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
@@ -258,11 +249,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: -0.25rem;
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: -0.25rem;
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
@@ -275,42 +266,30 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
|
||||
left: calc(var(--board-grid-item-inset, 0px) - 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,12 +46,14 @@
|
||||
|
||||
.contentMount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: var(--board-grid-item-inset, 0px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.staticItem:has([data-board-container-label]) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.editorEntry {
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
|
||||
@@ -130,6 +130,10 @@ 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;
|
||||
@@ -175,6 +179,7 @@ export const SectionGrid = ({
|
||||
columnCount,
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placementMaxRowCount,
|
||||
placements: displayPlacements,
|
||||
transactionPlacements: placements,
|
||||
className: editorClassName,
|
||||
@@ -190,6 +195,7 @@ export const SectionGrid = ({
|
||||
entryElementStore,
|
||||
isInteractionDisabled,
|
||||
maxRowCount,
|
||||
placementMaxRowCount,
|
||||
placements,
|
||||
rowCount,
|
||||
section,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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", () => {
|
||||
@@ -25,8 +27,12 @@ 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("-448px");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
|
||||
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")?.dataset.gridX).toBe("0");
|
||||
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
||||
|
||||
@@ -57,10 +63,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("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?.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?.dataset.gridW).toBe("3");
|
||||
expect(active?.dataset.gridH).toBe("4");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||
|
||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||
|
||||
describe("grid resize outline", () => {
|
||||
@@ -28,8 +30,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("200px");
|
||||
expect(outline?.style.height).toBe("424px");
|
||||
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
|
||||
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
|
||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||
|
||||
controller.schedule(null);
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
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;
|
||||
@@ -26,8 +38,8 @@
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.containerToggle :global(.mantine-Button-inner) {
|
||||
@@ -42,6 +54,24 @@
|
||||
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) => void;
|
||||
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => 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) => {
|
||||
(targetSectionId: string, maxRowCount?: number | null) => {
|
||||
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 ?? 9999;
|
||||
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
|
||||
if (columnCount === 0 || rowCount === 0) return previous;
|
||||
|
||||
const existingContainers = previous.sections
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
@@ -40,6 +41,7 @@ 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();
|
||||
@@ -96,7 +98,15 @@ export const BoardSelectionToolbar = () => {
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...(mainCanvasSection
|
||||
? [
|
||||
{
|
||||
value: mainCanvasSection.id,
|
||||
label: t("item.create.destination.mainCanvas"),
|
||||
maxRowCount: null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
@@ -105,6 +115,7 @@ export const BoardSelectionToolbar = () => {
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
|
||||
},
|
||||
];
|
||||
}),
|
||||
@@ -113,6 +124,7 @@ 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,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -244,7 +256,11 @@ export const BoardSelectionToolbar = () => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||
{placementOptions.map((opt) => (
|
||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||
<Menu.Item
|
||||
key={opt.value}
|
||||
disabled={opt.maxRowCount === undefined}
|
||||
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
|
||||
>
|
||||
{opt.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
|
||||
@@ -66,16 +66,6 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-switcher",
|
||||
title: t("switcher.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("switcher.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-user-menu",
|
||||
title: t("userMenu.title"),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconRobot,
|
||||
IconTool,
|
||||
@@ -22,6 +23,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||
import { BoardSwitcher } from "./board/board-switcher";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
@@ -41,6 +43,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
@@ -56,98 +59,111 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
||||
}, [logoutUrl]);
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<BoardSwitcher>
|
||||
{({ open, preload, hotkey }) => (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={open}
|
||||
onFocus={preload}
|
||||
onPointerEnter={preload}
|
||||
>
|
||||
{t("preferences")}
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
)}
|
||||
</BoardSwitcher>
|
||||
);
|
||||
};
|
||||
+213
-61
@@ -514,6 +514,9 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
||||
|
||||
interface DemoWidget {
|
||||
kind: WidgetKind;
|
||||
section?: "right";
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
needsIntegration: boolean;
|
||||
@@ -562,9 +565,9 @@ const demoApps = [
|
||||
href: "https://portainer.io",
|
||||
},
|
||||
{
|
||||
name: "Home Assistant",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
|
||||
href: "https://home-assistant.io",
|
||||
name: "Uptime Kuma",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
|
||||
href: "https://uptime.kuma.pet",
|
||||
},
|
||||
{
|
||||
name: "Nextcloud",
|
||||
@@ -583,69 +586,163 @@ const demoApps = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
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 },
|
||||
const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
|
||||
// Daily focus
|
||||
{ kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
|
||||
{
|
||||
kind: "bookmarks",
|
||||
width: 6,
|
||||
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,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
|
||||
options: {
|
||||
showToolbar: false,
|
||||
allowReadOnlyCheck: true,
|
||||
content: `<p style="text-align: center"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg" width="28%"></p><h2 style="text-align: center">Quick runbook</h2><p style="text-align: center">Keep today close: signals first, controls beside them, distractions out.</p><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Backups verified</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Review active alerts</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Connect your everyday services</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Tune this board to your routine</p></div></li></ul><p style="text-align: center"><em>Make it yours. Keep it useful.</em></p>`,
|
||||
},
|
||||
},
|
||||
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
|
||||
{ kind: "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: "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 },
|
||||
{
|
||||
kind: "rssFeed",
|
||||
width: 6,
|
||||
xOffset: 0,
|
||||
yOffset: 9,
|
||||
width: 3,
|
||||
height: 2,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
||||
maximumAmountPosts: 20,
|
||||
maximumAmountPosts: 12,
|
||||
textLinesClamp: 2,
|
||||
hideDescription: false,
|
||||
hideDescription: true,
|
||||
},
|
||||
},
|
||||
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
|
||||
{ kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true },
|
||||
|
||||
// Infrastructure and activity
|
||||
{ kind: "dockerContainers", xOffset: 0, yOffset: 11, width: 4, height: 3, needsIntegration: false },
|
||||
{ kind: "mediaReleases", xOffset: 4, yOffset: 11, width: 4, height: 3, needsIntegration: true },
|
||||
|
||||
// Community Workshop
|
||||
{
|
||||
kind: "customApi",
|
||||
xOffset: 8,
|
||||
yOffset: 11,
|
||||
width: 4,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 },
|
||||
},
|
||||
|
||||
// Network and health
|
||||
{
|
||||
kind: "healthMonitoring",
|
||||
xOffset: 3,
|
||||
yOffset: 5,
|
||||
width: 6,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
},
|
||||
{
|
||||
kind: "dnsHoleSummary",
|
||||
xOffset: 3,
|
||||
yOffset: 7,
|
||||
width: 3,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
options: { layout: "grid", usePiHoleColors: false },
|
||||
},
|
||||
{
|
||||
kind: "beszelSystemStats",
|
||||
xOffset: 6,
|
||||
yOffset: 7,
|
||||
width: 3,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
},
|
||||
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
|
||||
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
|
||||
|
||||
// Right app rail
|
||||
...appIds.map(
|
||||
(appId): DemoWidget => ({
|
||||
(appId, index): DemoWidget => ({
|
||||
kind: "app",
|
||||
width: 2,
|
||||
section: "right",
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: 1,
|
||||
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) => {
|
||||
@@ -710,44 +807,100 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
});
|
||||
}
|
||||
|
||||
const customWidgetDefinitionId = createId();
|
||||
const customWidgetDefinition = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Community Workshop",
|
||||
description: "Live Custom Widget and Custom CSS submissions shared by the Homarr community.",
|
||||
sources: {
|
||||
default: {
|
||||
name: "Homarr Workshop",
|
||||
baseUrl: "https://v2.preview.homarr.dev",
|
||||
networkScope: "public",
|
||||
auth: "none",
|
||||
},
|
||||
},
|
||||
requests: {
|
||||
workshop: {
|
||||
path: "/api/collections/workshop_listings/records",
|
||||
query: { perPage: 50 },
|
||||
cacheSeconds: 300,
|
||||
},
|
||||
},
|
||||
options: {},
|
||||
template: `<Stack p="md" gap="xs" h="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}><Text size="xs" c="indigo" fw={700}>COMMUNITY WORKSHOP</Text><Title order={3}>Made by Homarr users</Title></Stack>
|
||||
<Badge color="indigo" variant="light">{data.workshop?.totalItems ?? "—"} shared</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">Build Custom Widgets or dashboard CSS, then share it with the community.</Text>
|
||||
{status.workshop?.loading ? <Stack gap="xs"><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /></Stack> : status.workshop?.error ? <Stack gap="xs"><Alert color="red" title="The Workshop could not be loaded">{status.workshop.error}</Alert><RefreshButton /></Stack> : (data.workshop?.items ?? []).length === 0 ? <Stack gap="xs"><Alert color="gray" title="Nothing shared yet">The first community submission could be yours.</Alert><RefreshButton /></Stack> : <Stack gap={4} style={{ flex: 1 }}>{(data.workshop?.items ?? []).slice(0, 4).map(submission => <Group key={submission.id} justify="space-between" wrap="nowrap"><Text size="sm" fw={600} lineClamp={1}>{submission.title}</Text><Group gap="xs" wrap="nowrap"><Text size="xs" c="dimmed">@{submission.authorName ?? "community"}</Text><Badge size="xs" color="gray" variant="light">{submission.upvotes ?? 0} ↑</Badge></Group></Group>)}{data.workshop?.totalItems > 4 ? <Text size="xs" c="dimmed">and {data.workshop.totalItems - 4} more in the Workshop</Text> : null}</Stack>}
|
||||
<Anchor href="https://homarr.dev/docs/getting-started" target="_blank" rel="noreferrer" bg="indigo" c="white" fw={700} px="md" py="xs" radius="md" underline="never">Install Homarr now →</Anchor>
|
||||
</Stack>`,
|
||||
});
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: customWidgetDefinitionId,
|
||||
name: customWidgetDefinition.name,
|
||||
description: customWidgetDefinition.description ?? null,
|
||||
iconUrl: null,
|
||||
sources: SuperJSON.stringify(customWidgetDefinition.sources),
|
||||
requests: SuperJSON.stringify(customWidgetDefinition.requests),
|
||||
options: SuperJSON.stringify(customWidgetDefinition.options),
|
||||
template: customWidgetDefinition.template,
|
||||
enabled: true,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
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 sectionId = createId();
|
||||
const mainSectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
id: mainSectionId,
|
||||
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: 12,
|
||||
columnCount: 13,
|
||||
rightGutterColumnCount: 1,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
const demoWidgets = buildDemoWidgets(appIds);
|
||||
let xOffset = 0;
|
||||
let yOffset = 0;
|
||||
let rowMaxHeight = 0;
|
||||
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
|
||||
for (const widget of demoWidgets) {
|
||||
if (xOffset + widget.width > 12) {
|
||||
xOffset = 0;
|
||||
yOffset += rowMaxHeight;
|
||||
rowMaxHeight = 0;
|
||||
let sectionId = mainSectionId;
|
||||
if (widget.section === "right") {
|
||||
sectionId = rightSectionId;
|
||||
}
|
||||
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
|
||||
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
@@ -759,8 +912,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset,
|
||||
yOffset,
|
||||
xOffset: widget.xOffset,
|
||||
yOffset: widget.yOffset,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
});
|
||||
@@ -770,7 +923,6 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
xOffset += widget.width;
|
||||
}
|
||||
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||
|
||||
@@ -45,6 +45,169 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
|
||||
connection.close();
|
||||
});
|
||||
|
||||
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: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
|
||||
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
|
||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||
];
|
||||
|
||||
|
||||
@@ -3012,12 +3012,10 @@
|
||||
"label": "Animate weather icons"
|
||||
},
|
||||
"showHumidity": {
|
||||
"label": "Show humidity",
|
||||
"description": "Display humidity in current conditions, hourly details and daily forecasts"
|
||||
"label": "Show humidity"
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Show current wind speed",
|
||||
"description": "Only on current weather"
|
||||
"label": "Show current wind speed"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "Use mph for windspeed"
|
||||
@@ -5130,6 +5128,25 @@
|
||||
"addContent": "Add board content",
|
||||
"settings": "Board settings",
|
||||
"switch": "Switch board",
|
||||
"switcher": {
|
||||
"title": "Switch board",
|
||||
"search": {
|
||||
"empty": "No boards match \"{search}\"."
|
||||
},
|
||||
"results": "Available boards",
|
||||
"preview": "Preview of {name} with {count} items",
|
||||
"currentHidden": "Current board: {name}",
|
||||
"keyboard": {
|
||||
"navigate": "Navigate",
|
||||
"open": "Open",
|
||||
"instructions": "Type to filter boards. Use the arrow keys to move through results and Enter to open a board."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No other boards",
|
||||
"description": "There are no other boards you can access."
|
||||
},
|
||||
"error": "Boards could not be loaded."
|
||||
},
|
||||
"create": {
|
||||
"availability": {
|
||||
"checking": "Checking availability…",
|
||||
|
||||
@@ -82,7 +82,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Group>
|
||||
|
||||
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
|
||||
<Card withBorder p="xs">
|
||||
<Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
|
||||
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
|
||||
{status.broadcastMessage}
|
||||
</Text>
|
||||
@@ -143,7 +143,13 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
{status.items.map((item) => {
|
||||
const itemStatusKey = getStatusKey(item.status);
|
||||
return (
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Card
|
||||
key={item.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
@@ -201,6 +207,9 @@ export const getArchiveCompactLayout = (width: number, height: number, isAdvance
|
||||
|
||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||
|
||||
const 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" color="red" radius="xl" size="lg">
|
||||
<ThemeIcon variant="light" 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: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
background-color: transparent;
|
||||
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: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,11 @@ 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"
|
||||
@@ -101,7 +105,12 @@ 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>
|
||||
))}
|
||||
@@ -111,7 +120,11 @@ 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,5 +1,15 @@
|
||||
.card:hover {
|
||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible {
|
||||
@@ -8,14 +18,6 @@
|
||||
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);
|
||||
}
|
||||
@@ -23,3 +25,21 @@
|
||||
.bookmarkButton:focus-visible .bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.compactScrollViewport > div {
|
||||
display: flex !important;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.compactScrollContent {
|
||||
flex: 1;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -2,21 +2,9 @@
|
||||
|
||||
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 { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconLink } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -30,6 +18,8 @@ import classes from "./bookmark.module.css";
|
||||
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
|
||||
const COMPACT_GRID_GAP = 4;
|
||||
|
||||
export interface BookmarkDisplayItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -54,9 +44,14 @@ export function getCompactBookmarkLayout(
|
||||
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;
|
||||
let columns = 1;
|
||||
if (isGrid) {
|
||||
columns = Math.max(1, Math.min(count, Math.floor((width + COMPACT_GRID_GAP) / (targetWidth + COMPACT_GRID_GAP))));
|
||||
} else if (layout === "row") {
|
||||
columns = count;
|
||||
}
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
const cellWidth = width / columns;
|
||||
const cellWidth = (width - (columns - 1) * COMPACT_GRID_GAP) / columns;
|
||||
const cellHeight = height / rows;
|
||||
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
|
||||
|
||||
@@ -76,6 +71,7 @@ export default function BookmarksWidget({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const { ref: compactContentRef, width: compactContentWidth } = useElementSize<HTMLDivElement>();
|
||||
const apps =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.app.byIds.useQuery(options.items, {
|
||||
@@ -115,8 +111,8 @@ export default function BookmarksWidget({
|
||||
);
|
||||
|
||||
const compactLayout = useMemo(
|
||||
() => getCompactBookmarkLayout(width, height, data.length, options.layout),
|
||||
[width, height, data.length, options.layout],
|
||||
() => getCompactBookmarkLayout(compactContentWidth || width, height, data.length, options.layout),
|
||||
[compactContentWidth, data.length, height, options.layout, width],
|
||||
);
|
||||
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
|
||||
const compactHideHostname =
|
||||
@@ -139,8 +135,13 @@ export default function BookmarksWidget({
|
||||
/>
|
||||
)}
|
||||
{displayMode !== "advanced" && (
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box mih="100%">
|
||||
<ScrollArea
|
||||
classNames={{ viewport: classes.compactScrollViewport }}
|
||||
type="auto"
|
||||
scrollbarSize={6}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<div ref={compactContentRef} className={classes.compactScrollContent}>
|
||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
||||
<GridLayout
|
||||
data={data}
|
||||
@@ -167,7 +168,7 @@ export default function BookmarksWidget({
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -216,6 +217,7 @@ const FlexLayout = ({
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
bg="transparent"
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
@@ -300,7 +302,14 @@ const AdvancedBookmarksLayout = ({
|
||||
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%">
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
bg="transparent"
|
||||
withBorder={withBorder}
|
||||
p="md"
|
||||
h="100%"
|
||||
>
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<BookmarkIcon
|
||||
app={app}
|
||||
@@ -359,14 +368,7 @@ const GridLayout = ({
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing={4}
|
||||
verticalSpacing={4}
|
||||
miw="100%"
|
||||
mih="100%"
|
||||
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
|
||||
>
|
||||
<Flex gap={COMPACT_GRID_GAP} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
|
||||
{data.map((app) => {
|
||||
const href = getSafeAppHref(app.href);
|
||||
return (
|
||||
@@ -377,11 +379,16 @@ const GridLayout = ({
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
h="100%"
|
||||
style={{
|
||||
flex: `1 0 calc((100% - calc(${COMPACT_GRID_GAP * (columns - 1)}px * var(--mantine-scale, 1))) / ${columns})`,
|
||||
minWidth: 0,
|
||||
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
className={classes.card}
|
||||
bg="transparent"
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
@@ -407,7 +414,7 @@ const GridLayout = ({
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.errorIndicator {
|
||||
top: 4px;
|
||||
inset-inline-start: 4px;
|
||||
z-index: 1;
|
||||
padding: 2px;
|
||||
border: 1px solid rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.18));
|
||||
}
|
||||
@@ -180,14 +180,10 @@ const CalendarBase = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||
@@ -260,7 +256,13 @@ const CalendarBase = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{hasErrors && (
|
||||
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -291,7 +293,7 @@ const CalendarAgenda = ({
|
||||
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
|
||||
|
||||
return (
|
||||
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
|
||||
<Stack h="100%" w="100%" gap="sm" p="md" pos="relative" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
|
||||
@@ -327,7 +329,7 @@ const CalendarAgenda = ({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
|
||||
</Group>
|
||||
|
||||
@@ -135,14 +135,13 @@ 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}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.accordion {
|
||||
--coolify-neutral-fill: rgb(
|
||||
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12)
|
||||
);
|
||||
--coolify-neutral-border: rgb(
|
||||
from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)
|
||||
);
|
||||
}
|
||||
|
||||
.accordion :global(.mantine-Accordion-item) {
|
||||
--item-border-color: var(--coolify-neutral-border);
|
||||
--item-filled-color: var(--coolify-neutral-fill);
|
||||
}
|
||||
|
||||
.accordion :global(.mantine-Accordion-control) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.accordion :global(.mantine-Accordion-control:not(:disabled, [data-disabled]):hover) {
|
||||
background-color: var(--coolify-neutral-fill);
|
||||
}
|
||||
|
||||
.neutralDividerBottom {
|
||||
border-bottom: 1px solid var(--coolify-neutral-border);
|
||||
}
|
||||
|
||||
.neutralDividerTop {
|
||||
border-top: 1px solid var(--coolify-neutral-border);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { 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";
|
||||
@@ -71,12 +72,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card p={0} radius="sm">
|
||||
<Card p={0} radius="sm" bg="transparent">
|
||||
<Group
|
||||
p="xs"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
|
||||
className={classes.neutralDividerBottom}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
@@ -135,6 +136,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
className={classes.accordion}
|
||||
variant="filled"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
@@ -175,11 +177,7 @@ 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}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{version}
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -82,6 +83,7 @@ export function SingleInstanceLayout({
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
className={classes.accordion}
|
||||
variant="contained"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
@@ -123,11 +125,7 @@ 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}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
[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);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -195,6 +195,9 @@ 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 }}>
|
||||
@@ -206,9 +209,11 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
className="summary-card"
|
||||
p="sm"
|
||||
radius={board.itemRadius}
|
||||
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
|
||||
bg={backgroundColor}
|
||||
style={{
|
||||
flex: 1,
|
||||
border:
|
||||
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -146,8 +148,9 @@ const createColumns = (
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{inlineState && <ContainerStateDot state={container.state} />}
|
||||
<Avatar
|
||||
radius="xl"
|
||||
size={24}
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
size={20}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={container.iconUrl}
|
||||
style={{ flexShrink: 0 }}
|
||||
@@ -432,6 +435,7 @@ 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"}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Keep Docker rows translucent so board wallpaper and the inherited item opacity remain visible. */
|
||||
.homarr-data-table.docker-table {
|
||||
--mantine-datatable-background-color: transparent !important;
|
||||
--mantine-datatable-striped-color: transparent !important;
|
||||
}
|
||||
@@ -143,11 +143,6 @@ const stateColorMap: Record<DownloadState, string> = {
|
||||
unknown: "gray",
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -606,7 +601,7 @@ export default function DownloadClientsWidget({
|
||||
title: t("items.state.columnTitle"),
|
||||
width: 90,
|
||||
render: (record) => (
|
||||
<Badge size="xs" variant="light" color={stateColorMap[record.state]}>
|
||||
<Badge size="xs" variant="dot" color={stateColorMap[record.state]}>
|
||||
{t(`states.${record.state}`)}
|
||||
</Badge>
|
||||
),
|
||||
@@ -784,7 +779,6 @@ 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,
|
||||
@@ -900,7 +894,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
||||
{t(`states.${item.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
@@ -27,6 +27,7 @@ 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;
|
||||
@@ -243,7 +244,7 @@ const FirewallPanel = ({
|
||||
t,
|
||||
tCommon,
|
||||
}: FirewallPanelProps) => (
|
||||
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
|
||||
<Paper className={classes.card} withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0} bg="transparent">
|
||||
<Stack gap="xs">
|
||||
{isAdvanced && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
@@ -382,6 +383,7 @@ 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,6 +1,7 @@
|
||||
import { Box, Select } from "@mantine/core";
|
||||
|
||||
import type { Firewall } from "./component";
|
||||
import classes from "./firewall.module.css";
|
||||
|
||||
interface FirewallMenuProps {
|
||||
onChange: (value: string | null) => void;
|
||||
@@ -18,6 +19,7 @@ export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall
|
||||
w="100%"
|
||||
color="lightgray"
|
||||
data={dropdownItems}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: "24px",
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
[data-mantine-color-scheme="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
.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="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
.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);
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
[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);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.systemPanel {
|
||||
|
||||
@@ -191,7 +191,7 @@ export const SystemHealthMonitoring = ({
|
||||
))}
|
||||
</Flex>
|
||||
{isAdvanced && (
|
||||
<Card radius={board.itemRadius} p="sm">
|
||||
<Card className={classes.card} radius={board.itemRadius} p="sm">
|
||||
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
.statItem {
|
||||
padding: 12px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
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;
|
||||
}
|
||||
|
||||
.statItemDense {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 4px 6px;
|
||||
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));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.statItem,
|
||||
.statItemDense {
|
||||
border-color: CanvasText;
|
||||
}
|
||||
}
|
||||
@@ -55,42 +55,49 @@ export default function ImmichServerStatsWidget({
|
||||
Number(statVisibility.showStorage);
|
||||
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
[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);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
}
|
||||
|
||||
.root {
|
||||
@@ -31,7 +28,7 @@
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,19 @@
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
border: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease;
|
||||
|
||||
@mixin dark {
|
||||
--border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
@mixin light {
|
||||
--border-color: var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
background-color: rgb(
|
||||
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.14)
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -29,6 +25,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(.mediaMissing-wrapper) :global(.mantine-SimpleGrid-root) > :global(.mantine-Paper-root) {
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:global(.mediaMissing-wrapper) :global(.mantine-Tabs-list) {
|
||||
--tab-border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
--tab-hover-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.14));
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -36,11 +36,37 @@ import classes from "./component.module.css";
|
||||
import type { MediaMissingTab } from "./tabs";
|
||||
import { resolveMediaMissingTab } from "./tabs";
|
||||
|
||||
type Density = "thin" | "compact" | "comfortable";
|
||||
|
||||
const MIN_CARD_WIDTH = 280;
|
||||
const MAX_COLUMNS = 4;
|
||||
const GRID_HORIZONTAL_PADDING = 20;
|
||||
const GRID_COLUMN_GAP = 10;
|
||||
|
||||
const columnBreakpoints = Array.from({ length: MAX_COLUMNS }, (_, index) => {
|
||||
const columns = MAX_COLUMNS - index;
|
||||
return {
|
||||
columns,
|
||||
minimumWidth: columns * MIN_CARD_WIDTH + (columns - 1) * GRID_COLUMN_GAP,
|
||||
};
|
||||
});
|
||||
|
||||
const getMediaMissingGridLayout = (panelWidth: number) => {
|
||||
const contentWidth = Math.max(0, panelWidth - GRID_HORIZONTAL_PADDING);
|
||||
const breakpoint = columnBreakpoints.find((candidate) => contentWidth >= candidate.minimumWidth);
|
||||
return { columns: breakpoint?.columns ?? 1, contentWidth };
|
||||
};
|
||||
|
||||
const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: boolean): Density => {
|
||||
if (isThin) return "thin";
|
||||
if (contentWidth > 0 && contentWidth / columns < 180) return "compact";
|
||||
return "comfortable";
|
||||
};
|
||||
|
||||
export default function MediaMissingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
@@ -68,6 +94,12 @@ export default function MediaMissingWidget({
|
||||
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
|
||||
}, [activeTab, selectedTab]);
|
||||
|
||||
const enabledPanelCount = Number(showMissing) + Number(showQueued);
|
||||
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
|
||||
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
|
||||
const gridLayout = useMemo(() => getMediaMissingGridLayout(panelWidth), [panelWidth]);
|
||||
const density = getMediaMissingDensity(gridLayout.contentWidth, gridLayout.columns, isThin);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
if (data.length === 0) throw new NoIntegrationDataError();
|
||||
if (!showMissing && !showQueued)
|
||||
@@ -85,14 +117,6 @@ export default function MediaMissingWidget({
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
|
||||
|
||||
const enabledPanelCount = Number(showMissing) + Number(showQueued);
|
||||
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
|
||||
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
|
||||
const isShort = !isAdvanced && height > 0 && height < 180;
|
||||
const targetCardWidth = isShort ? 130 : 200;
|
||||
const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
|
||||
const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
|
||||
|
||||
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
|
||||
|
||||
const renderPanel = (
|
||||
@@ -114,7 +138,7 @@ export default function MediaMissingWidget({
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
|
||||
<SimpleGrid cols={Math.min(gridLayout.columns, entries.length)} spacing="xs" verticalSpacing="xs">
|
||||
{entries.map(({ item, integrationId }) => (
|
||||
<MediaCard
|
||||
key={`${integrationId}-${item.type}-${item.id}`}
|
||||
@@ -226,8 +250,6 @@ export default function MediaMissingWidget({
|
||||
);
|
||||
}
|
||||
|
||||
type Density = "thin" | "compact" | "comfortable";
|
||||
|
||||
const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 };
|
||||
|
||||
const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 };
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
.card {
|
||||
min-width: 0;
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ const MediaRequestCard = ({
|
||||
<Image
|
||||
className="mediaRequests-list-item-poster"
|
||||
src={request.posterImagePath}
|
||||
h={isDense ? 36 : 44}
|
||||
h={isDense ? 32 : 40}
|
||||
w="auto"
|
||||
radius="sm"
|
||||
alt=""
|
||||
@@ -157,7 +157,7 @@ const MediaRequestCard = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap={2} w="100%" miw={0}>
|
||||
<Stack gap={0} w="100%" miw={0}>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||
@@ -274,7 +274,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
className={`mediaRequests-list-item-pending-button-approve ${actionTargetClasses.root}`}
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
size="xs"
|
||||
disabled={!canInteract || isPending}
|
||||
aria-label={t("pending.approve")}
|
||||
onClick={() => {
|
||||
@@ -289,7 +289,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
className={`mediaRequests-list-item-pending-button-decline ${actionTargetClasses.root}`}
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
size="xs"
|
||||
disabled={!canInteract || isPending}
|
||||
aria-label={t("pending.decline")}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
[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);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.userCard {
|
||||
@@ -13,7 +9,7 @@
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -147,7 +147,7 @@ function StreamTableHeader({
|
||||
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
||||
|
||||
return (
|
||||
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||
<Table.Th style={{ width }} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||
{sortable ? (
|
||||
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
||||
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
|
||||
|
||||
@@ -44,6 +43,24 @@ const viewIcons = {
|
||||
statistics: IconReportAnalytics,
|
||||
} satisfies Record<View, TablerIcon>;
|
||||
|
||||
type TranscodingControlMode = "buttons" | "select";
|
||||
|
||||
const narrowWidthLayout = { minimumWidth: 0, advancedColumns: 1, controlMode: "select" } as const;
|
||||
|
||||
const widthBreakpoints = [
|
||||
{ minimumWidth: 1100, advancedColumns: 3, controlMode: "buttons" },
|
||||
{ minimumWidth: 700, advancedColumns: 2, controlMode: "buttons" },
|
||||
{ minimumWidth: 560, advancedColumns: 1, controlMode: "buttons" },
|
||||
narrowWidthLayout,
|
||||
] as const satisfies readonly {
|
||||
minimumWidth: number;
|
||||
advancedColumns: number;
|
||||
controlMode: TranscodingControlMode;
|
||||
}[];
|
||||
|
||||
const getTranscodingWidthLayout = (width: number) =>
|
||||
widthBreakpoints.find((candidate) => width >= candidate.minimumWidth) ?? narrowWidthLayout;
|
||||
|
||||
export default function MediaTranscodingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
@@ -91,6 +108,7 @@ export default function MediaTranscodingWidget({
|
||||
const handleQueuePageChange = (page: number) => {
|
||||
setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
|
||||
};
|
||||
const widthLayout = useMemo(() => getTranscodingWidthLayout(width), [width]);
|
||||
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
@@ -108,7 +126,7 @@ export default function MediaTranscodingWidget({
|
||||
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
||||
{queryIndicator}
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
|
||||
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
|
||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
|
||||
</AdvancedPanel>
|
||||
@@ -147,35 +165,45 @@ export default function MediaTranscodingWidget({
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
|
||||
<SegmentedControl
|
||||
data={views.map((value) => {
|
||||
const Icon = viewIcons[value];
|
||||
return {
|
||||
label: (
|
||||
<Center style={{ gap: 4 }}>
|
||||
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
{footerLayout.showTabLabels ? (
|
||||
<Text span size="xs">
|
||||
{t(`tab.${value}`)}
|
||||
</Text>
|
||||
) : (
|
||||
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
|
||||
)}
|
||||
</Center>
|
||||
),
|
||||
{widthLayout.controlMode === "buttons" ? (
|
||||
<Button.Group style={{ flex: 1, minWidth: 0 }}>
|
||||
{views.map((value) => {
|
||||
const Icon = viewIcons[value];
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
size="compact-xs"
|
||||
variant={value === view ? "filled" : "default"}
|
||||
leftSection={<Icon size="var(--mantine-font-size-xs)" />}
|
||||
aria-pressed={value === view}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => setView(value)}
|
||||
>
|
||||
{t(`tab.${value}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Button.Group>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={view}
|
||||
data={views.map((value) => ({
|
||||
value,
|
||||
};
|
||||
})}
|
||||
value={view}
|
||||
onChange={(value) => {
|
||||
const nextView = viewBySegmentValue[value];
|
||||
if (nextView) {
|
||||
setView(nextView);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
style={{ minWidth: 0, flexShrink: footerLayout.showTabLabels ? 1 : 0 }}
|
||||
/>
|
||||
label: t(`tab.${value}`),
|
||||
}))}
|
||||
aria-label={t("option.defaultView.label")}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
|
||||
const nextView = viewBySegmentValue[value];
|
||||
if (nextView) {
|
||||
setView(nextView);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap="xs" ml="auto" wrap="nowrap">
|
||||
{view === "queue" && queuePageCount > 1 && (
|
||||
|
||||
@@ -97,7 +97,7 @@ function StatisticItem(props: StatisticItemProps) {
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Tooltip label={props.label}>
|
||||
<Card p={0} radius={board.itemRadius} miw={48} flex={1}>
|
||||
<Card p={0} radius={board.itemRadius} miw={48} flex={1} bg="transparent">
|
||||
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
|
||||
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<Text size="md">{props.value}</Text>
|
||||
|
||||
@@ -14,6 +14,9 @@ import { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
|
||||
const neutralBorderColor =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
export default function NetworkControllerNetworkStatusWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
@@ -69,7 +72,7 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} withBorder p="sm">
|
||||
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
@@ -87,10 +90,20 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={layout.columns} spacing="sm">
|
||||
<Card p={layout.cardPadding} withBorder>
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
|
||||
</Card>
|
||||
<Card p={layout.cardPadding} withBorder>
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
@@ -105,9 +118,15 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
return (
|
||||
<Stack p={layout.padding} h="100%" gap={0}>
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
|
||||
<SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}>
|
||||
{layout.showWifi && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder={layout.withBorder}
|
||||
bg="transparent"
|
||||
h="100%"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
@@ -117,7 +136,13 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
</Card>
|
||||
)}
|
||||
{layout.showWired && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder={layout.withBorder}
|
||||
bg="transparent"
|
||||
h="100%"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
|
||||
@@ -10,11 +10,11 @@ export const StatRow = ({
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1}>
|
||||
<Stack gap={2} align="center" justify="center">
|
||||
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="center">
|
||||
{value}
|
||||
</Text>
|
||||
<Text size={compact ? "sm" : "md"} c="dimmed">
|
||||
<Text size={compact ? "sm" : "md"} c="dimmed" ta="center">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, SimpleGrid, Text } from "@mantine/core";
|
||||
import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconWifi } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -18,17 +18,19 @@ export const WifiVariant = ({
|
||||
}) => {
|
||||
const t = useI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
|
||||
<IconWifi size={24} />
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
<Center w={24} h={24}>
|
||||
<IconWifi size={20} />
|
||||
</Center>
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wifi.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, SimpleGrid, Text } from "@mantine/core";
|
||||
import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -18,17 +18,19 @@ export const WiredVariant = ({
|
||||
}) => {
|
||||
const t = useI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
|
||||
<IconNetwork size={24} />
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
<Center w={24} h={24}>
|
||||
<IconNetwork size={20} />
|
||||
</Center>
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wired.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -83,7 +83,13 @@ export default function NetworkControllerSummaryWidget({
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={controllerColumns} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} p="sm" withBorder>
|
||||
<Card
|
||||
key={integration.id}
|
||||
p="sm"
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
@@ -105,7 +111,13 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Group>
|
||||
<SimpleGrid cols={matrixColumns} spacing="xs">
|
||||
{getNetworkControllerMatrix(summary).map((section) => (
|
||||
<Card key={section.key} p="xs" withBorder>
|
||||
<Card
|
||||
key={section.key}
|
||||
p="xs"
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
@@ -149,12 +161,18 @@ export default function NetworkControllerSummaryWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
|
||||
<Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}>
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
<SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}>
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
|
||||
<Stack gap="xs">
|
||||
<Card
|
||||
key={integration.id}
|
||||
p={isDense ? "xs" : "sm"}
|
||||
withBorder={summaries.length > 1}
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Stack h="100%" gap={isDense ? 4 : 8} justify="center" align="center">
|
||||
{summaries.length > 1 && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
@@ -165,7 +183,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
|
||||
<List spacing={isDense ? 2 : 4} center size={isDense ? "xs" : "sm"} w="fit-content" mx="auto">
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
@@ -211,7 +229,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
size="var(--mantine-font-size-xl)"
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -239,7 +257,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,6 +280,9 @@ const getMatrixSectionLabel = (
|
||||
return t(`card.${key}` as never);
|
||||
};
|
||||
|
||||
const neutralSurfaceBorder =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
const StatusIcon = ({
|
||||
status,
|
||||
label,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.tiptap hr {
|
||||
border-top-style: double;
|
||||
border-top-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] {
|
||||
@@ -20,6 +21,10 @@
|
||||
outline: 3px solid rgba(0, 65, 198, 0.8);
|
||||
}
|
||||
|
||||
.tiptap table :where(tr) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tiptap table {
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
@@ -28,8 +33,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tiptap table td {
|
||||
border-color: var(--mantine-color-gray-5) !important;
|
||||
.tiptap table :where(th, td) {
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)) !important;
|
||||
border-width: 1px !important;
|
||||
border-style: solid !important;
|
||||
box-sizing: border-box;
|
||||
@@ -39,7 +44,7 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tiptap table td > * {
|
||||
.tiptap table :where(th, td) > * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -66,6 +71,23 @@
|
||||
.tiptap[contenteditable="false"].resize-cursor {
|
||||
pointer-events: none;
|
||||
}
|
||||
.homarr-notebook-toolbar-group {
|
||||
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
|
||||
}
|
||||
|
||||
.homarr-notebook .tiptap :where(pre, blockquote) {
|
||||
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
|
||||
}
|
||||
|
||||
.homarr-notebook .tiptap :where(code, kbd) {
|
||||
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.homarr-notebook .tiptap :where(pre) :where(code) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
.ProseMirror {
|
||||
height: 100%;
|
||||
|
||||
@@ -121,8 +121,6 @@ export function Notebook({
|
||||
}, [options.allowReadOnlyCheck]);
|
||||
|
||||
const { primaryColor } = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const { mutateAsync, isPending: isSaving } = clientApi.widget.notebook.updateContent.useMutation({
|
||||
scope: { id: `notebook-content:${boardId ?? "preview"}:${itemId ?? "preview"}` },
|
||||
});
|
||||
@@ -360,9 +358,9 @@ export function Notebook({
|
||||
isEditing && !isSaving ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined
|
||||
}
|
||||
editor={editor}
|
||||
styles={(theme) => ({
|
||||
styles={() => ({
|
||||
root: {
|
||||
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
display: "flex",
|
||||
@@ -371,6 +369,8 @@ export function Notebook({
|
||||
},
|
||||
toolbar: {
|
||||
backgroundColor: "transparent",
|
||||
borderColor:
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))",
|
||||
padding: "0.5rem",
|
||||
},
|
||||
content: {
|
||||
@@ -390,7 +390,7 @@ export function Notebook({
|
||||
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.Bold title={tControls("bold")} />
|
||||
<RichTextEditor.Italic title={tControls("italic")} />
|
||||
<RichTextEditor.Strikethrough title={tControls("strikethrough")} />
|
||||
@@ -401,14 +401,14 @@ export function Notebook({
|
||||
<RichTextEditor.ClearFormatting title={tControls("clear")} />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.H1 title={tControls("heading", { level: "1" })} />
|
||||
<RichTextEditor.H2 title={tControls("heading", { level: "2" })} />
|
||||
<RichTextEditor.H3 title={tControls("heading", { level: "3" })} />
|
||||
<RichTextEditor.H4 title={tControls("heading", { level: "4" })} />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.AlignLeft
|
||||
title={tControls("align", {
|
||||
position: t("align.left"),
|
||||
@@ -442,12 +442,12 @@ export function Notebook({
|
||||
</RichTextEditor.Control>
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.Blockquote title={tControls("blockquote")} />
|
||||
<RichTextEditor.Hr title={tControls("horizontalLine")} />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.BulletList title={tControls("bulletList")} />
|
||||
<RichTextEditor.OrderedList title={tControls("orderedList")} />
|
||||
<TaskListToggle />
|
||||
@@ -461,13 +461,13 @@ export function Notebook({
|
||||
)}
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.Link title={tControls("link")} />
|
||||
<RichTextEditor.Unlink title={tControls("unlink")} />
|
||||
<EmbedImage />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<TableToggle />
|
||||
{editor?.isActive("table") && (
|
||||
<>
|
||||
@@ -483,7 +483,7 @@ export function Notebook({
|
||||
)}
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
|
||||
<RichTextEditor.Undo />
|
||||
<RichTextEditor.Redo />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.card,
|
||||
.row {
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
}
|
||||
|
||||
.row {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getNotificationDisplay } from "./display";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
@@ -97,17 +98,13 @@ export default function NotificationsWidget({
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
className={columns > 1 ? classes.card : classes.row}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
|
||||
bg={columns > 1 ? undefined : "transparent"}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
borderBottom:
|
||||
columns > 1
|
||||
? undefined
|
||||
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
|
||||
}}
|
||||
>
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
gap: clamp(8px, 3cqi, 18px);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-bottom: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.heroExpanded {
|
||||
@@ -97,7 +98,9 @@
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
color: var(--mantine-color-text);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: background-color 120ms ease;
|
||||
background-color: transparent;
|
||||
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -24,3 +29,9 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.entry {
|
||||
border-color: CanvasText;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -8,7 +8,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export interface SpeedStatCardProps {
|
||||
icon: TablerIcon;
|
||||
color: string;
|
||||
color: "blue" | "teal" | "orange" | "green" | "red";
|
||||
value: string;
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
@@ -19,15 +19,22 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
const board = useRequiredBoard();
|
||||
const isWide = width > height + 20;
|
||||
const hideLabel = height > 0 && height <= 38;
|
||||
const surfaceColor = `var(--mantine-color-${color}-filled)`;
|
||||
const accentColor = `var(--mantine-color-${color}-5)`;
|
||||
const surfaceAlpha = compact ? 0.1 : 0.14;
|
||||
const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
|
||||
const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
return (
|
||||
<Tooltip label={`${label}: ${value}`} withArrow>
|
||||
<Box
|
||||
<Card
|
||||
ref={ref}
|
||||
p={compact ? "xs" : "sm"}
|
||||
bg={`var(--mantine-color-${color}-light)`}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
bg={surfaceBackground}
|
||||
h="100%"
|
||||
style={{ flex: 1, borderRadius: `var(--mantine-radius-${board.itemRadius})`, minWidth: 0 }}
|
||||
style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
|
||||
>
|
||||
<Flex
|
||||
h="100%"
|
||||
@@ -38,7 +45,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
gap={isWide ? 8 : 4}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
|
||||
<Icon size={compact ? 16 : 20} color={accentColor} style={{ flexShrink: 0 }} />
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
|
||||
{value}
|
||||
@@ -51,7 +58,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
{hideLabel && <VisuallyHidden>{label}</VisuallyHidden>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } from "@mantine/core";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -18,6 +18,34 @@ import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
|
||||
type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||
|
||||
const advancedColumnBreakpoints = [
|
||||
{ minimumWidth: 1820, columns: 7 },
|
||||
{ minimumWidth: 1560, columns: 6 },
|
||||
{ minimumWidth: 1300, columns: 5 },
|
||||
{ minimumWidth: 1040, columns: 4 },
|
||||
{ minimumWidth: 780, columns: 3 },
|
||||
{ minimumWidth: 520, columns: 2 },
|
||||
{ minimumWidth: 0, columns: 1 },
|
||||
] as const;
|
||||
|
||||
const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => {
|
||||
if (!isAdvanced) {
|
||||
return {
|
||||
columns: 1,
|
||||
showSecondaryText: height >= 160 && width >= 260,
|
||||
showTemperature: height >= 100 && width >= 220,
|
||||
};
|
||||
}
|
||||
|
||||
const breakpoint = advancedColumnBreakpoints.find((candidate) => width >= candidate.minimumWidth);
|
||||
const columns = Math.min(diskCount, breakpoint?.columns ?? 1);
|
||||
return {
|
||||
columns: Math.max(1, columns),
|
||||
showSecondaryText: true,
|
||||
showTemperature: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
|
||||
|
||||
export const getDisplayText = (
|
||||
@@ -85,7 +113,6 @@ const SystemDiskCard = ({
|
||||
showTemperature,
|
||||
}: SystemDiskCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const scheme = useMantineColorScheme();
|
||||
const t = useI18n("widget.systemDisks");
|
||||
const valueRef = useRef<HTMLParagraphElement>(null);
|
||||
const [valueFits, setValueFits] = useState(true);
|
||||
@@ -120,6 +147,8 @@ const SystemDiskCard = ({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -131,8 +160,9 @@ const SystemDiskCard = ({
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
py="xs"
|
||||
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
|
||||
style={{ overflow: "hidden", position: "relative" }}
|
||||
withBorder
|
||||
bg={backgroundColor}
|
||||
style={{ overflow: "hidden", position: "relative", borderColor }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
@@ -199,6 +229,27 @@ export default function SystemResources({
|
||||
const data = results.filter(
|
||||
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
|
||||
);
|
||||
const disks = data.flatMap((entry) => {
|
||||
const fileSystem = filterStorageVolumes(
|
||||
entry.healthInfo.fileSystem,
|
||||
options.visibleStorageVolumes,
|
||||
entry.integrationId,
|
||||
);
|
||||
const smart = filterStorageVolumes(entry.healthInfo.smart, options.visibleStorageVolumes, entry.integrationId);
|
||||
return fileSystem.map((item) => ({
|
||||
integrationId: entry.integrationId,
|
||||
integrationName: entry.integrationName,
|
||||
item,
|
||||
smartItem: smart.find(
|
||||
(candidate) => normalizeStorageDeviceName(candidate.deviceName) === normalizeStorageDeviceName(item.deviceName),
|
||||
),
|
||||
}));
|
||||
});
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const layout = useMemo(
|
||||
() => getSystemDisksLayout(width, height, disks.length, isAdvanced),
|
||||
[disks.length, height, isAdvanced, width],
|
||||
);
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
@@ -217,40 +268,18 @@ export default function SystemResources({
|
||||
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
|
||||
if (data.length === 0) return emptyState;
|
||||
|
||||
const disks = data.flatMap((entry) => {
|
||||
const fileSystem = filterStorageVolumes(
|
||||
entry.healthInfo.fileSystem,
|
||||
options.visibleStorageVolumes,
|
||||
entry.integrationId,
|
||||
);
|
||||
const smart = filterStorageVolumes(entry.healthInfo.smart, options.visibleStorageVolumes, entry.integrationId);
|
||||
return fileSystem.map((item) => ({
|
||||
integrationId: entry.integrationId,
|
||||
integrationName: entry.integrationName,
|
||||
item,
|
||||
smartItem: smart.find(
|
||||
(candidate) => normalizeStorageDeviceName(candidate.deviceName) === normalizeStorageDeviceName(item.deviceName),
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0);
|
||||
const hasFailedSource = results.some((entry) => Boolean(entry.error));
|
||||
if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError();
|
||||
if (hasNoFileSystems || disks.length === 0) return emptyState;
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const minimumCardWidth = 260;
|
||||
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
|
||||
const cellWidth = width / columns;
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={columns} spacing="xs" p="xs">
|
||||
<SimpleGrid cols={layout.columns} spacing="xs" p="xs">
|
||||
{disks.map(({ integrationId, integrationName, item, smartItem }) => {
|
||||
const freeText = t("status.free", {
|
||||
percentage: String(Math.round(100 - clampPercentage(item.percentage))),
|
||||
@@ -273,8 +302,8 @@ export default function SystemResources({
|
||||
freeText={advancedDisplayTexts.free}
|
||||
smartStatus={smartItem?.overallStatus}
|
||||
isAdvanced={isAdvanced}
|
||||
showSecondaryText={isAdvanced || (height >= 160 && cellWidth >= 260)}
|
||||
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
|
||||
showSecondaryText={layout.showSecondaryText}
|
||||
showTemperature={layout.showTemperature}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AreaChartSeries } from "@mantine/charts";
|
||||
import { AreaChart, LineChart } from "@mantine/charts";
|
||||
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||
import type { TooltipProps, YAxisProps } from "recharts";
|
||||
|
||||
@@ -36,13 +36,12 @@ export const CommonChart = ({
|
||||
}) => {
|
||||
const { ref: elementSizeRef, height } = useElementSize();
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const board = useRequiredBoard();
|
||||
const { hovered, ref: hoverRef } = useHover();
|
||||
const ref = useMergedRef(elementSizeRef, hoverRef);
|
||||
|
||||
const opacity = board.opacity / 100;
|
||||
const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
|
||||
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
|
||||
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||
@@ -53,7 +52,7 @@ export const CommonChart = ({
|
||||
ref={ref}
|
||||
h={"100%"}
|
||||
pos={"relative"}
|
||||
style={{ overflow: "visible" }}
|
||||
style={{ overflow: "visible", border: `1px solid ${borderColor}` }}
|
||||
p={0}
|
||||
bg={backgroundColor}
|
||||
radius={board.itemRadius}
|
||||
@@ -119,7 +118,13 @@ export const CommonChart = ({
|
||||
withYAxis={false}
|
||||
withDots={false}
|
||||
bg={backgroundColor}
|
||||
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
|
||||
styles={{
|
||||
root: {
|
||||
padding: 5,
|
||||
borderRadius: theme.radius[board.itemRadius],
|
||||
backgroundColor,
|
||||
},
|
||||
}}
|
||||
tooltipAnimationDuration={200}
|
||||
tooltipProps={tooltipProps}
|
||||
withTooltip={height >= 64}
|
||||
|
||||
@@ -230,18 +230,19 @@ const SystemCharts = ({
|
||||
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
|
||||
const chartCount = visibleCharts.length;
|
||||
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
|
||||
const compactChartGap = 8;
|
||||
const chartHeight = isAdvanced
|
||||
? 180
|
||||
: `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`;
|
||||
: `calc((100% - ${Math.max(0, chartCount - 1) * compactChartGap}px) / ${Math.max(1, chartCount)})`;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" h={isAdvanced ? "auto" : height} miw={0}>
|
||||
<Stack gap={isAdvanced ? "xs" : compactChartGap} h={isAdvanced ? "auto" : height} miw={0}>
|
||||
{showTitle && (
|
||||
<Text size="sm" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1, minHeight: 0 }}>
|
||||
<SimpleGrid cols={chartColumns} spacing={isAdvanced ? "xs" : compactChartGap} style={{ flex: 1, minHeight: 0 }}>
|
||||
{chartCount === 0 && (
|
||||
<Center h="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function RecentActivityList({
|
||||
: session.mediaTitle;
|
||||
|
||||
return (
|
||||
<Paper key={session.key} p="sm" radius="lg">
|
||||
<Paper key={session.key} p="sm" radius="lg" bg="transparent">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" />
|
||||
|
||||
@@ -44,7 +44,7 @@ export function StatsBar({
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
|
||||
return (
|
||||
<Paper p="xs" radius="lg">
|
||||
<Paper p="xs" radius="lg" bg="transparent">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{icon}
|
||||
<Stack gap={0} style={{ overflow: "hidden" }}>
|
||||
|
||||
@@ -65,7 +65,7 @@ function StreamCard({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper p="xs" radius="lg" style={{ position: "relative", overflow: "hidden" }}>
|
||||
<Paper p="xs" radius="lg" bg="transparent" style={{ position: "relative", overflow: "hidden" }}>
|
||||
{stream.posterUrl && (
|
||||
<Box
|
||||
style={{
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ViolationsList({
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{violations.map((violation) => (
|
||||
<Paper key={violation.key} p="xs" radius="lg">
|
||||
<Paper key={violation.key} p="xs" radius="lg" bg="transparent">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<IconAlertTriangle
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
gap: clamp(5px, 2cqi, 8px);
|
||||
padding: clamp(6px, 3cqi, 10px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.metricIcon {
|
||||
@@ -75,7 +75,7 @@
|
||||
.protocolCard {
|
||||
min-width: 0;
|
||||
padding: clamp(6px, 3cqi, 10px);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.chartSurface {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.chartSurface :global(.mantine-ScrollArea-viewport),
|
||||
.chartSurface :global(.mantine-ScrollArea-scrollbar),
|
||||
.chartSurface :global(.recharts-wrapper),
|
||||
.chartSurface :global(.recharts-surface) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.listSurface {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.32));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { formatDuration } from "@homarr/common";
|
||||
import type { UmamiVisitorStats } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import { UmamiEventsContent } from "./umami-events-content";
|
||||
import { getUmamiLayout } from "./umami-layout";
|
||||
@@ -161,7 +162,7 @@ export function UmamiContent({
|
||||
);
|
||||
|
||||
const advancedLists = (
|
||||
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
|
||||
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="xs" mih={180}>
|
||||
{viewMode !== "topPages" && (
|
||||
<UmamiTopPagesContent
|
||||
integrationIds={integrationIds}
|
||||
@@ -182,7 +183,7 @@ export function UmamiContent({
|
||||
);
|
||||
|
||||
const advancedContent = stackAdvancedContent ? (
|
||||
<ScrollArea h="100%">
|
||||
<ScrollArea className={classes.chartSurface} h="100%">
|
||||
<Stack gap="md">
|
||||
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
|
||||
{advancedLists}
|
||||
@@ -190,15 +191,17 @@ export function UmamiContent({
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<Box h="100%" mih={260}>
|
||||
<Box className={classes.chartSurface} h="100%" mih={260}>
|
||||
{selectedView}
|
||||
</Box>
|
||||
<ScrollArea h="100%">{advancedLists}</ScrollArea>
|
||||
<ScrollArea className={classes.chartSurface} h="100%">
|
||||
{advancedLists}
|
||||
</ScrollArea>
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%" pos="relative">
|
||||
<Stack gap={isDense ? 1 : 4} p={isDense ? 2 : "xs"} h="100%" pos="relative">
|
||||
<Group justify="space-between" align="baseline" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
|
||||
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
|
||||
@@ -264,7 +267,7 @@ export function UmamiContent({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||
{displayMode === "advanced" ? advancedContent : selectedView}
|
||||
</Box>
|
||||
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UmamiMetricItem } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import { umamiQueryOptions } from "./umami-utils";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
@@ -52,7 +53,7 @@ function UmamiTopList({
|
||||
const locale = useCurrentIntlLocale();
|
||||
|
||||
return (
|
||||
<Stack gap={2} h="100%">
|
||||
<Stack className={classes.listSurface} gap={2} h="100%">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{heading}
|
||||
</Text>
|
||||
|
||||
@@ -35,6 +35,11 @@ const statusColors: Record<UpsStatus, string> = {
|
||||
unknown: "gray",
|
||||
};
|
||||
|
||||
const neutralSurfaceBackground =
|
||||
"rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
const neutralSurfaceBorder =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
type UpsLayout = "mini" | "compact" | "full";
|
||||
|
||||
export default function UpsWidget({
|
||||
@@ -183,7 +188,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
|
||||
|
||||
if (layout === "mini") {
|
||||
return (
|
||||
<Card p={6} radius="md">
|
||||
<Card p={6} radius="md" bg="transparent">
|
||||
<Group gap={4} wrap="nowrap" justify="center" mb={showRing ? 4 : 0}>
|
||||
<Box
|
||||
w={8}
|
||||
@@ -211,6 +216,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
|
||||
size={44}
|
||||
thickness={4}
|
||||
roundCaps
|
||||
rootColor={neutralSurfaceBorder}
|
||||
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
|
||||
label={
|
||||
<Text ta="center" size="10px" fw={700}>
|
||||
@@ -226,13 +232,14 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
|
||||
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Card p={6} radius="md">
|
||||
<Card p={6} radius="md" bg="transparent">
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
{showRing && summary.batteryCharge !== null && (
|
||||
<RingProgress
|
||||
size={40}
|
||||
thickness={4}
|
||||
roundCaps
|
||||
rootColor={neutralSurfaceBorder}
|
||||
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
|
||||
label={
|
||||
<Text ta="center" size="9px" fw={700}>
|
||||
@@ -258,7 +265,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
|
||||
}
|
||||
|
||||
return (
|
||||
<Card p="xs" radius="md">
|
||||
<Card p="xs" radius="md" bg="transparent">
|
||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
@@ -279,6 +286,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
|
||||
size={68}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
rootColor={neutralSurfaceBorder}
|
||||
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700}>
|
||||
@@ -301,7 +309,12 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
|
||||
</Text>
|
||||
<Text size="xs">{Math.round(summary.load)}%</Text>
|
||||
</Group>
|
||||
<Progress value={clampPercent(summary.load)} size="sm" color={getLoadColor(summary.load)} />
|
||||
<Progress
|
||||
value={clampPercent(summary.load)}
|
||||
size="sm"
|
||||
color={getLoadColor(summary.load)}
|
||||
styles={{ root: { backgroundColor: neutralSurfaceBackground } }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
gap: clamp(8px, 3cqi, 18px);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-bottom: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.heroExpanded {
|
||||
@@ -83,7 +84,9 @@
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import { getStatusColor, RUNNING_STATUS } from "./helpers";
|
||||
|
||||
export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"];
|
||||
|
||||
const neutralBorderColor =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
export function VpnIntegrationCard({
|
||||
vpn,
|
||||
integrationName,
|
||||
@@ -53,7 +56,14 @@ export function VpnIntegrationCard({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card withBorder radius={board.itemRadius} p={dense ? 6 : "xs"} w="100%">
|
||||
<Card
|
||||
withBorder
|
||||
radius={board.itemRadius}
|
||||
p={dense ? 6 : "xs"}
|
||||
w="100%"
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
.dailyCard,
|
||||
.detailMetric,
|
||||
.solarDetails {
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 72%, transparent);
|
||||
border: 0.0625rem solid color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
|
||||
background-color: transparent;
|
||||
border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.dailyCard {
|
||||
@@ -43,8 +43,8 @@
|
||||
|
||||
.dailyCardButton:hover .dailyCard,
|
||||
.dailyCardButton:focus-visible .dailyCard {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 30%, var(--mantine-color-body));
|
||||
border-color: color-mix(in srgb, var(--mantine-primary-color-filled) 45%, var(--mantine-color-default-border));
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.72));
|
||||
}
|
||||
|
||||
.dailyDetailsDropdown {
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
.solarDetails {
|
||||
background: color-mix(in srgb, var(--mantine-color-yellow-light) 24%, var(--mantine-color-body));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.detailsButton,
|
||||
|
||||
@@ -69,13 +69,13 @@ describe("weather layout", () => {
|
||||
});
|
||||
|
||||
describe("weather defaults", () => {
|
||||
it("starts rich and static while preserving saved false values", () => {
|
||||
it("starts minimal and static while preserving saved false values", () => {
|
||||
const options = definition.createOptions();
|
||||
expect(options.showCity.defaultValue).toBe(true);
|
||||
expect(options.hasForecast.defaultValue).toBe(true);
|
||||
expect(options.showCity.defaultValue).toBe(false);
|
||||
expect(options.hasForecast.defaultValue).toBe(false);
|
||||
expect(options.forecastDayCount.defaultValue).toBe(3);
|
||||
expect(options.animateIcons.defaultValue).toBe(false);
|
||||
expect(options.showHumidity.defaultValue).toBe(true);
|
||||
expect(options.showHumidity.defaultValue).toBe(false);
|
||||
|
||||
const reduced = reduceWidgetOptionsWithDefinition(definition, {} as never, {
|
||||
showCity: false,
|
||||
|
||||
@@ -26,7 +26,7 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
||||
isFormatFahrenheit: factory.switch(),
|
||||
disableTemperatureDecimals: factory.switch(),
|
||||
animateIcons: factory.switch(),
|
||||
showHumidity: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
showHumidity: factory.switch({ defaultValue: false, withDescription: true }),
|
||||
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
||||
useImperialSpeed: factory.switch(),
|
||||
location: factory.location({
|
||||
@@ -50,8 +50,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
||||
defaultValue: "dddd, MMMM D",
|
||||
withDescription: true,
|
||||
}),
|
||||
showCity: factory.switch({ defaultValue: true }),
|
||||
hasForecast: factory.switch({ defaultValue: true }),
|
||||
showCity: factory.switch({ defaultValue: false }),
|
||||
hasForecast: factory.switch({ defaultValue: false }),
|
||||
forecastDayCount: factory.slider({
|
||||
defaultValue: 3,
|
||||
validate: z.number().min(1).max(7),
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
[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);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
}
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user