Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77dbbb2acf |
No files matched your search
@@ -113,7 +113,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Set desktop and mobile home boards for the current user
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
|
||||
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
|
||||
|
||||
|
||||
@@ -133,21 +133,20 @@ On larger canvases, the complete board grows together like browser zoom.
|
||||
The complete canvas always fits the available width and never adds horizontal board scrolling.
|
||||
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the main layout and starts at `768px` on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
|
||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
|
||||
unused breakpoint above Mobile.
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
|
||||
their breakpoints.
|
||||
|
||||
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
|
||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
|
||||
available breakpoint, and you can then change the breakpoint, columns, and rails.
|
||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
|
||||
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
|
||||
|
||||
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
|
||||
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
|
||||
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
|
||||
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
|
||||
Duplicate breakpoints are normalized without discarding saved positions.
|
||||
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
|
||||
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
|
||||
|
||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||
|
||||
@@ -183,8 +182,7 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
|
||||
#### Breakpoint
|
||||
|
||||
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
|
||||
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
|
||||
eligible layout with the highest breakpoint, regardless of its role.
|
||||
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
||||
fallback to select a layout.
|
||||
|
||||
@@ -273,8 +271,6 @@ Expanding it smoothly restores those bounds and moves following items out of the
|
||||
|
||||
### Background
|
||||
|
||||
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
|
||||
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -50,7 +46,6 @@ import mediaRequestList from "./img/list.png";
|
||||
|
||||
- Approve / decline a request by clicking on the thumbs up or down icon.
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
|
||||
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestStatsWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -47,7 +43,6 @@ import mediaRequestStats from "./img/stats.png";
|
||||
### Interactions
|
||||
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
|
||||
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ interface Props {
|
||||
saveSettingsAsync: () => Promise<FormValues | null>;
|
||||
}
|
||||
|
||||
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
|
||||
|
||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tLayout = useI18n("layout");
|
||||
@@ -103,9 +101,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const displayedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
@@ -139,9 +134,13 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{displayedLayouts.map(({ layout, index }) => {
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
|
||||
.map((candidate) => candidate.breakpoint);
|
||||
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
|
||||
|
||||
return (
|
||||
<Fieldset
|
||||
@@ -191,8 +190,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
min={layout.role === "mobile" ? 0 : 1}
|
||||
max={32767}
|
||||
min={
|
||||
layout.role === "base"
|
||||
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
||||
: 1
|
||||
}
|
||||
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
.refreshIndicator {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: calc(var(--mantine-spacing-sm) / 2);
|
||||
right: calc(var(--mantine-spacing-sm) / 2);
|
||||
top: -34px;
|
||||
right: 14px;
|
||||
z-index: 201;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -52,6 +66,7 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settingsButton {
|
||||
right: 4px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
@@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({
|
||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||
[definition, item, settings],
|
||||
);
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const isWidgetRefreshing =
|
||||
useIsFetching({
|
||||
predicate: (query) =>
|
||||
query.state.data !== undefined &&
|
||||
matchesWidgetItemQuery(
|
||||
query.queryKey,
|
||||
widgetQueryKeys,
|
||||
{
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
integrationIds: item.integrationIds,
|
||||
options: menuItem.options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
definition.queryMatcher,
|
||||
),
|
||||
}) > 0;
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
@@ -323,6 +343,11 @@ const LoadedBoardItemContent = ({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
{isWidgetRefreshing && (
|
||||
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||
<Loader size="xs" />
|
||||
</Box>
|
||||
)}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
|
||||
@@ -336,7 +361,6 @@ const LoadedBoardItemContent = ({
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
disabled={isAdvanced}
|
||||
>
|
||||
<Box
|
||||
ref={sourceRef}
|
||||
|
||||
@@ -44,7 +44,6 @@ interface WidgetContextMenuProps {
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -54,7 +53,6 @@ export const WidgetContextMenu = ({
|
||||
previewDimensions,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
disabled = false,
|
||||
children,
|
||||
}: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
@@ -164,7 +162,6 @@ export const WidgetContextMenu = ({
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
setMenuOpened(false);
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
@@ -233,7 +230,6 @@ export const WidgetContextMenu = ({
|
||||
|
||||
const handleOpenAdvancedFocus = useCallback(() => {
|
||||
if (!sourceRef.current) return;
|
||||
setMenuOpened(false);
|
||||
openAdvancedFocus(item.id, sourceRef.current, {
|
||||
restoreFocusTarget:
|
||||
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
|
||||
@@ -260,7 +256,7 @@ export const WidgetContextMenu = ({
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
>
|
||||
<Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
|
||||
{canOpenAdvancedFocus && (
|
||||
<>
|
||||
|
||||
@@ -49,14 +49,12 @@
|
||||
}
|
||||
|
||||
.widgetIcon {
|
||||
width: var(--mantine-font-size-sm);
|
||||
height: var(--mantine-font-size-sm);
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
@@ -96,7 +94,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--chip-background);
|
||||
font-size: var(--mantine-font-size-xxs);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
@@ -24,6 +23,11 @@
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-inverse-scale, 1));
|
||||
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-inverse-scale, 1));
|
||||
--mantine-radius-md: calc(0.5rem * var(--board-canvas-inverse-scale, 1));
|
||||
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-inverse-scale, 1));
|
||||
--mantine-radius-xl: calc(1rem * var(--board-canvas-inverse-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));
|
||||
@@ -46,13 +50,4 @@
|
||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
|
||||
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
|
||||
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
|
||||
* above, just never ported over to radius. */
|
||||
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
.board-grid-entry:has([data-board-widget-header]):hover,
|
||||
.board-grid-entry:has([data-board-widget-header]):focus-within {
|
||||
z-index: var(--homarr-z-index-widget-focus);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"] {
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
||||
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
||||
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
||||
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
||||
while growing during a drag happening anywhere else on the board - any container (not just
|
||||
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
|
||||
inside that section's DOM tree, so the stray value cascades straight through and silently
|
||||
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
|
||||
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
|
||||
unset, not merely inherited. Reset it here so every container can never inherit a stray
|
||||
value from anywhere above it, no matter what any ancestor's own drag state is doing.
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||
.viewport[data-section-kind="container"] {
|
||||
--board-grid-drag-height: initial;
|
||||
}
|
||||
|
||||
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
|
||||
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
|
||||
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
|
||||
@@ -27,14 +11,17 @@
|
||||
.viewport.scrollableViewport {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* The grid content inside has a fixed pixel width matching this viewport's full
|
||||
outer width (needed for the drag/resize math), so there's no room to reserve
|
||||
for a scrollbar without it eating into - or overlapping - the content on
|
||||
whichever side it renders. Hiding the native scrollbar track entirely (wheel/
|
||||
touch/drag scrolling still works) avoids that instead of trying to reserve
|
||||
space for it. */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
||||
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
||||
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
||||
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
||||
while growing during a drag happening anywhere else on the board - a nested scrollable
|
||||
container sits inside that section's DOM tree, so the stray value cascades straight
|
||||
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in
|
||||
section-grid.tsx, which only kicks in when the property is entirely unset, not merely
|
||||
inherited. Reset it here so this element can never inherit a stray value from anywhere
|
||||
above it, no matter what any ancestor's own drag state is doing.
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||
--board-grid-drag-height: initial;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,25 +148,8 @@ export const SectionGrid = ({
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
|
||||
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
|
||||
// not like the grid's own un-compensated logical-pixel math, so it needs the same
|
||||
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
|
||||
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
|
||||
// all, the header bar covers the first row of the container's content instead of sitting
|
||||
// above it. Only the vertical axis is affected - the toggle spans the full width already.
|
||||
const collapsibleHeaderInset =
|
||||
section.kind === "container" && section.options.collapsible
|
||||
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
|
||||
: 0;
|
||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||
const viewportHeight = Math.max(
|
||||
1,
|
||||
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
|
||||
);
|
||||
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
|
||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||
@@ -300,7 +283,6 @@ export const SectionGrid = ({
|
||||
{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||
marginTop: collapsibleHeaderInset || undefined,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -335,9 +317,6 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Matches the collapsible container toggle's h={24} in container-section.tsx.
|
||||
const CONTAINER_HEADER_HEIGHT = 24;
|
||||
|
||||
const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header[data-visible="false"] {
|
||||
.header[data-desktop-visible="false"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -73,38 +73,39 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.userMenu {
|
||||
display: flex;
|
||||
padding-inline: rem(6px);
|
||||
}
|
||||
|
||||
.mobileContent {
|
||||
display: none;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.mobileZone {
|
||||
.mobileIdentity,
|
||||
.mobileActions {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobileZone::-webkit-scrollbar {
|
||||
display: none;
|
||||
.mobileIdentity {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobileZone[data-zone="left"] {
|
||||
justify-content: flex-start;
|
||||
.mobileIdentity > :last-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobileZone[data-zone="right"] {
|
||||
justify-content: flex-end;
|
||||
max-width: 72vw;
|
||||
.mobileIdentity h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobileActions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.floatingControls {
|
||||
@@ -154,12 +155,21 @@
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.header[data-desktop-visible="false"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopContent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileContent {
|
||||
display: grid;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.floatingControls,
|
||||
.floatingControlsCorner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -73,10 +81,7 @@ export const ConfigurableHeader = ({
|
||||
return (
|
||||
<BoardSwitcher>
|
||||
{(boardSwitcher) => {
|
||||
const renderItems = (
|
||||
items: HeaderItem[],
|
||||
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
|
||||
) =>
|
||||
const renderItems = (items: HeaderItem[]) =>
|
||||
items.map((item) => (
|
||||
<HeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
@@ -90,8 +95,8 @@ export const ConfigurableHeader = ({
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||
searchDisplay={display.search}
|
||||
logoDisplay={display.logo}
|
||||
searchDisplay={headerPreferences.searchDisplay}
|
||||
logoDisplay={headerPreferences.logoDisplay}
|
||||
boardSwitcher={boardSwitcher}
|
||||
assistant={assistant}
|
||||
openDockerModal={openDockerModal}
|
||||
@@ -101,19 +106,13 @@ export const ConfigurableHeader = ({
|
||||
/>
|
||||
));
|
||||
|
||||
const desktopDisplay = {
|
||||
search: headerPreferences.searchDisplay,
|
||||
logo: headerPreferences.logoDisplay,
|
||||
};
|
||||
const mobileDisplay = { search: "icon", logo: "logo" } as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
className={classes.header}
|
||||
data-visible={headerPreferences.visible}
|
||||
data-desktop-visible={headerPreferences.visible}
|
||||
data-advanced-focus-background
|
||||
data-app-shell-header
|
||||
>
|
||||
@@ -121,26 +120,37 @@ export const ConfigurableHeader = ({
|
||||
<div className={classes.content}>
|
||||
<div className={classes.zone} data-zone="left">
|
||||
{hasNavigation ? <ClientBurger /> : null}
|
||||
{renderItems(headerPreferences.zones.left, desktopDisplay)}
|
||||
{renderItems(headerPreferences.zones.left)}
|
||||
</div>
|
||||
<div className={classes.zone} data-zone="center">
|
||||
{renderItems(headerPreferences.zones.center, desktopDisplay)}
|
||||
{renderItems(headerPreferences.zones.center)}
|
||||
</div>
|
||||
<div className={classes.zone} data-zone="right">
|
||||
{renderItems(headerPreferences.zones.right, desktopDisplay)}
|
||||
{renderItems(headerPreferences.zones.right)}
|
||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.mobileContent}>
|
||||
<Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
|
||||
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
|
||||
{hasNavigation ? <ClientBurger /> : null}
|
||||
{renderItems(headerPreferences.zones.left, mobileDisplay)}
|
||||
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
|
||||
</Group>
|
||||
<Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
|
||||
{renderItems(headerPreferences.zones.center, mobileDisplay)}
|
||||
{renderItems(headerPreferences.zones.right, mobileDisplay)}
|
||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
|
||||
{actions}
|
||||
{boardEditAction}
|
||||
{boardSettingsAction}
|
||||
<TourTarget id="board-search">
|
||||
<MobileSearchButton alwaysVisible />
|
||||
</TourTarget>
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
@@ -288,9 +298,7 @@ const HeaderItem = ({
|
||||
return (
|
||||
<Tooltip label={boardUnavailableLabel}>
|
||||
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
||||
<Avatar size={22} radius="sm">
|
||||
?
|
||||
</Avatar>
|
||||
<IconLayoutDashboard size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -408,19 +416,14 @@ const HeaderItem = ({
|
||||
|
||||
return (
|
||||
<TourTarget id="board-user-menu">
|
||||
<div className={classes.userMenu}>
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</div>
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const getBoardInitial = (name: string) => {
|
||||
const firstCharacter = Array.from(name.trim())[0] ?? "?";
|
||||
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
|
||||
};
|
||||
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
|
||||
@@ -22,17 +22,11 @@ export const ClientShell = ({
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
const backgroundProps = useOptionalBackgroundProps();
|
||||
const { headerPreferences } = useSettings();
|
||||
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
|
||||
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
|
||||
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
|
||||
// background image only covers as much height as the content needs, so collapsing a
|
||||
// container short enough leaves flat page background showing below it instead of the
|
||||
// background continuing to the bottom of the viewport.
|
||||
mih={backgroundProps.bg ? "100dvh" : undefined}
|
||||
header={hasHeader ? { height: headerHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconReplace,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -49,8 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
const board = useOptionalBoard();
|
||||
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
@@ -72,15 +67,6 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
{board && boardPermissions?.hasChangeAccess && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/boards/${encodeURIComponent(board.name)}/settings`}
|
||||
leftSection={<IconLayoutDashboard size="1rem" />}
|
||||
>
|
||||
{tBoard("action.settings")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||
|
||||
@@ -163,6 +163,7 @@ describe("lazy widget application graph", () => {
|
||||
});
|
||||
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
||||
|
||||
const refreshedDownloadsResponse = page.waitForResponse(
|
||||
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||
@@ -170,6 +171,7 @@ describe("lazy widget application graph", () => {
|
||||
releaseRefresh();
|
||||
releaseRefresh = undefined;
|
||||
await refreshedDownloadsResponse;
|
||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
||||
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
|
||||
@@ -2776,11 +2776,14 @@ export const boardLayoutsNeedRepair = (
|
||||
) => {
|
||||
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
|
||||
const baseLayout = baseLayouts.at(0);
|
||||
|
||||
return (
|
||||
mobileLayouts.length !== 1 ||
|
||||
baseLayouts.length !== 1 ||
|
||||
mobileLayouts.at(0)?.breakpoint !== 0 ||
|
||||
!baseLayout ||
|
||||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
|
||||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
|
||||
);
|
||||
};
|
||||
|
||||
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
|
||||
}
|
||||
}
|
||||
|
||||
const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
|
||||
const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
|
||||
const mobileLayout = normalizedLayouts[0];
|
||||
const baseLayout = normalizedLayouts.at(-1);
|
||||
if (mobileLayout && baseLayout) {
|
||||
await insertMissingProjectedPositionsAsync(
|
||||
db,
|
||||
|
||||
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
|
||||
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
|
||||
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
|
||||
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
|
||||
const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
|
||||
let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
|
||||
if (hasProtectedLayouts) {
|
||||
orderedLayouts = [
|
||||
mobileLayout,
|
||||
...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
|
||||
];
|
||||
}
|
||||
const orderedLayouts =
|
||||
mobileLayout && baseLayout
|
||||
? [
|
||||
mobileLayout,
|
||||
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
|
||||
baseLayout,
|
||||
]
|
||||
: sourceLayouts.toSorted(compareBoardLayouts);
|
||||
|
||||
let previousBreakpoint = -1;
|
||||
return orderedLayouts.map((layout, index) => {
|
||||
@@ -49,12 +49,7 @@ export const normalizeBoardLayoutRoles = <
|
||||
index === 0
|
||||
? 0
|
||||
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
|
||||
let role: LayoutRole = layout.role ?? "custom";
|
||||
if (!hasProtectedLayouts) {
|
||||
if (index === 0) role = "mobile";
|
||||
else if (index === orderedLayouts.length - 1) role = "base";
|
||||
else role = "custom";
|
||||
}
|
||||
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
|
||||
previousBreakpoint = breakpoint;
|
||||
return { ...layout, breakpoint, role };
|
||||
});
|
||||
|
||||
@@ -63,19 +63,13 @@ export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||
airQuality: { width: 2, height: 1 },
|
||||
calendar: { width: 2, height: 2 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
downloads: { width: 4, height: 2 },
|
||||
indexerManager: { width: 2, height: 3 },
|
||||
timer: { width: 2, height: 1 },
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
patchmon: { width: 2, height: 2 },
|
||||
mediaServer: { width: 3, height: 2 },
|
||||
"mediaRequests-requestList": { width: 3, height: 2 },
|
||||
"mediaRequests-requestStats": { width: 2, height: 2 },
|
||||
mediaMissing: { width: 2, height: 2 },
|
||||
mediaMissing: { width: 4, height: 3 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
assistant: { width: 10, height: 4 },
|
||||
};
|
||||
@@ -49,7 +49,8 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
|
||||
message: tBoard("action.create.notification.success.message", { name: values.name }),
|
||||
});
|
||||
const name = encodeURIComponent(result.name);
|
||||
router.push(`/boards/${name}`);
|
||||
const layoutId = encodeURIComponent(result.layoutId);
|
||||
router.push(`/boards/${name}?layout=${layoutId}&edit=true&add=true`);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface ModalFormFooterProps {
|
||||
onCancel: () => void;
|
||||
form?: string;
|
||||
submitLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
@@ -19,38 +18,24 @@ const ActionButtons = ({
|
||||
submitLabel,
|
||||
cancelLabel,
|
||||
loading,
|
||||
form,
|
||||
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading" | "form">) => {
|
||||
const t = useI18n("common.action");
|
||||
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading">) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={onCancel} variant="subtle" color="gray">
|
||||
{cancelLabel ?? t("cancel")}
|
||||
{cancelLabel ?? t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={form} loading={loading}>
|
||||
{submitLabel ?? t("saveChanges")}
|
||||
<Button type="submit" loading={loading}>
|
||||
{submitLabel ?? t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalFormFooter = ({
|
||||
onCancel,
|
||||
form,
|
||||
submitLabel,
|
||||
cancelLabel,
|
||||
loading,
|
||||
leftSection,
|
||||
}: ModalFormFooterProps) => {
|
||||
export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => {
|
||||
const actionButtons = (
|
||||
<ActionButtons
|
||||
onCancel={onCancel}
|
||||
form={form}
|
||||
submitLabel={submitLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
loading={loading}
|
||||
/>
|
||||
<ActionButtons onCancel={onCancel} submitLabel={submitLabel} cancelLabel={cancelLabel} loading={loading} />
|
||||
);
|
||||
|
||||
if (leftSection) {
|
||||
|
||||
@@ -2430,16 +2430,6 @@
|
||||
"label": "Animate weather icon",
|
||||
"description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested."
|
||||
},
|
||||
"colorWeatherByDayNight": {
|
||||
"label": "Color weather by day/night",
|
||||
"description": "Tint the weather icon and temperature with a day color or a night color, based on whether it's currently day or night at the chosen location."
|
||||
},
|
||||
"dayWeatherColor": {
|
||||
"label": "Day color"
|
||||
},
|
||||
"nightWeatherColor": {
|
||||
"label": "Night color"
|
||||
},
|
||||
"worldClockCities": {
|
||||
"label": "World clock cities",
|
||||
"description": "Add and order up to six cities for advanced view."
|
||||
@@ -4227,11 +4217,7 @@
|
||||
},
|
||||
"mediaRequests-requestList": {
|
||||
"name": "Media Requests List",
|
||||
"description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance",
|
||||
"empty": {
|
||||
"title": "No media requests yet",
|
||||
"description": "Search for a movie or show to start a request."
|
||||
},
|
||||
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
|
||||
"option": {
|
||||
"linksTargetNewTab": {
|
||||
"label": "Open links in new tab"
|
||||
@@ -4271,10 +4257,6 @@
|
||||
"mediaRequests-requestStats": {
|
||||
"name": "Media Requests Stats",
|
||||
"description": "Statistics about your media requests",
|
||||
"empty": {
|
||||
"title": "No request activity yet",
|
||||
"description": "Stats will appear after the first media request."
|
||||
},
|
||||
"titles": {
|
||||
"stats": {
|
||||
"main": "Media Stats",
|
||||
@@ -4680,6 +4662,12 @@
|
||||
"noSectionsEnabled": "Enable at least one section in widget options.",
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch speedtest data"
|
||||
},
|
||||
"range": {
|
||||
"twelveHours": "12h",
|
||||
"oneDay": "24h",
|
||||
"all": "All",
|
||||
"lastThirty": "Last 30"
|
||||
}
|
||||
},
|
||||
"uptimeKuma": {
|
||||
@@ -4853,8 +4841,7 @@
|
||||
"memory": "MEM",
|
||||
"network": "NET",
|
||||
"up": "UP",
|
||||
"down": "DOWN",
|
||||
"memoryTooltip": "{used} / {available} ({percent}%)"
|
||||
"down": "DOWN"
|
||||
}
|
||||
},
|
||||
"systemDisks": {
|
||||
@@ -5426,7 +5413,7 @@
|
||||
"title": "Layout",
|
||||
"responsive": {
|
||||
"title": "Responsive layouts",
|
||||
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
|
||||
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.",
|
||||
"action": {
|
||||
"add": "Add layout"
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ export const responsiveBoardLayoutsSchema = z
|
||||
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
|
||||
}
|
||||
|
||||
if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
|
||||
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
|
||||
}
|
||||
|
||||
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const headerBuiltinItemIds = [
|
||||
"user",
|
||||
] as const;
|
||||
|
||||
export const requiredHeaderBuiltinItemIds = ["user"] as const;
|
||||
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const;
|
||||
|
||||
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
||||
export const headerItemIds = headerBuiltinItemIds;
|
||||
|
||||
@@ -23,16 +23,11 @@ import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
const CONTROLS_ROW_HEIGHT = 36;
|
||||
const CONTROLS_ROW_GAP = 16;
|
||||
const STACK_PADDING = 24;
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
@@ -261,10 +256,6 @@ export default function BeszelSystemStatsWidget({
|
||||
systemId={selectedSystem?.systemId ?? ""}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
availableHeight={Math.max(
|
||||
0,
|
||||
height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP),
|
||||
)}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
|
||||
@@ -62,20 +62,9 @@ interface BeszelStatsViewProps {
|
||||
timePeriod: BeszelTimePeriod;
|
||||
visibility: BeszelStatsVisibility;
|
||||
columns: 1 | 2;
|
||||
availableHeight?: number;
|
||||
onSwitchToHistorical?: () => void;
|
||||
}
|
||||
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
const PANEL_ROW_OVERHEAD = 28;
|
||||
const GRID_ROW_GAP = 16;
|
||||
|
||||
const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
|
||||
if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT;
|
||||
const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD;
|
||||
return Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, Math.floor(perRow)));
|
||||
};
|
||||
|
||||
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
|
||||
|
||||
export function BeszelStatsView({
|
||||
@@ -84,7 +73,6 @@ export function BeszelStatsView({
|
||||
timePeriod,
|
||||
visibility,
|
||||
columns,
|
||||
availableHeight,
|
||||
onSwitchToHistorical,
|
||||
}: BeszelStatsViewProps) {
|
||||
const t = useI18n("widget.beszelSystemStats");
|
||||
@@ -260,29 +248,14 @@ export function BeszelStatsView({
|
||||
);
|
||||
}
|
||||
|
||||
const renderedPanelCount =
|
||||
(visibility.cpu && cpuData.length > 0 ? 1 : 0) +
|
||||
(visibility.memory && memoryData.length > 0 ? 1 : 0) +
|
||||
(visibility.disk && diskData.length > 0 ? 1 : 0) +
|
||||
(visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
|
||||
(visibility.network && networkData.length > 0 ? 1 : 0) +
|
||||
(showDocker && containerSeries.length > 0
|
||||
? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
|
||||
(visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
|
||||
(visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0)
|
||||
: 0);
|
||||
const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2;
|
||||
const rowCount = Math.ceil(renderedPanelCount / effectiveColumns);
|
||||
const chartHeight = computeChartHeight(availableHeight, rowCount);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={effectiveColumns} spacing="md">
|
||||
<SimpleGrid cols={columns} spacing="md">
|
||||
{visibility.cpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
@@ -296,7 +269,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
@@ -310,7 +283,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
type: "stacked",
|
||||
series: diskSeries,
|
||||
@@ -324,7 +297,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
@@ -337,7 +310,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
@@ -352,7 +325,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
@@ -366,7 +339,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemoryData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
@@ -380,7 +353,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: chartHeight,
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetworkData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -14,25 +13,15 @@ interface CalendarDayProps {
|
||||
rootHeight: number;
|
||||
}
|
||||
|
||||
const fallbackDayScale = { textSize: "xs" as const, indicatorSize: 3, indicatorMarginTop: 2 };
|
||||
const dayScaleTiers = [
|
||||
{ minAxisSize: 700, textSize: "lg" as const, indicatorSize: 6, indicatorMarginTop: 6 },
|
||||
{ minAxisSize: 450, textSize: "md" as const, indicatorSize: 5, indicatorMarginTop: 4 },
|
||||
{ minAxisSize: 250, textSize: "sm" as const, indicatorSize: 4, indicatorMarginTop: 3 },
|
||||
{ minAxisSize: 0, ...fallbackDayScale },
|
||||
];
|
||||
|
||||
const getDayScale = (minAxisSize: number) =>
|
||||
dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale;
|
||||
|
||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const mantineTheme = useMantineTheme();
|
||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||
|
||||
const minAxisSize = Math.min(rootWidth, rootHeight);
|
||||
const shouldScaleDown = minAxisSize < 350;
|
||||
const shouldShowIndicators = rootHeight >= 256;
|
||||
const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
|
||||
const indicatorSize = shouldScaleDown ? 3 : 4;
|
||||
|
||||
const cell = (
|
||||
<Container
|
||||
@@ -50,15 +39,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text ta={"center"} size={textSize} lh={1}>
|
||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
<NotificationIndicator
|
||||
events={events}
|
||||
size={indicatorSize}
|
||||
marginTop={indicatorMarginTop}
|
||||
visible={shouldShowIndicators}
|
||||
/>
|
||||
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -86,20 +70,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
interface NotificationIndicatorProps {
|
||||
events: CalendarEvent[];
|
||||
size: number;
|
||||
marginTop: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => {
|
||||
const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
|
||||
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
|
||||
(color): color is string => Boolean(color),
|
||||
);
|
||||
|
||||
if (!visible || notificationEvents.length === 0) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
mt={marginTop}
|
||||
mt={3}
|
||||
w="fit-content"
|
||||
maw="75%"
|
||||
h={size}
|
||||
|
||||
@@ -212,7 +212,6 @@ const CalendarBase = ({
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
padding: 3,
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
|
||||
@@ -132,9 +132,6 @@ const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "option
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
colorByDayNight={options.colorWeatherByDayNight}
|
||||
dayColor={options.dayWeatherColor}
|
||||
nightColor={options.nightWeatherColor}
|
||||
detailed
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
@@ -54,9 +54,6 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
colorByDayNight={options.colorWeatherByDayNight}
|
||||
dayColor={options.dayWeatherColor}
|
||||
nightColor={options.nightWeatherColor}
|
||||
detailed={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -12,23 +12,6 @@ import {
|
||||
|
||||
const timeZoneOptions = getTimeZoneOptions();
|
||||
|
||||
// Mantine theme color names, used to let users pick their own day/night weather colors.
|
||||
const weatherColorOptions: string[] = [
|
||||
"red",
|
||||
"pink",
|
||||
"grape",
|
||||
"violet",
|
||||
"indigo",
|
||||
"blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"green",
|
||||
"lime",
|
||||
"yellow",
|
||||
"orange",
|
||||
"gray",
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
supportsAdvancedFocus: true,
|
||||
@@ -118,18 +101,6 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
colorWeatherByDayNight: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
dayWeatherColor: factory.select({
|
||||
options: weatherColorOptions,
|
||||
defaultValue: "orange",
|
||||
}),
|
||||
nightWeatherColor: factory.select({
|
||||
options: weatherColorOptions,
|
||||
defaultValue: "blue",
|
||||
}),
|
||||
worldClockCities: factory.timezoneList({
|
||||
defaultValue: defaultWorldClockCities.map((city) => ({ ...city })),
|
||||
maxValues: maximumWorldClockCities,
|
||||
@@ -160,15 +131,6 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
animateWeatherIcon: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
colorWeatherByDayNight: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
dayWeatherColor: {
|
||||
shouldHide: (options) => !options.showWeather || !options.colorWeatherByDayNight,
|
||||
},
|
||||
nightWeatherColor: {
|
||||
shouldHide: (options) => !options.showWeather || !options.colorWeatherByDayNight,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -14,9 +14,6 @@ interface ClockWeatherSummaryProps {
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
animateIcon: boolean;
|
||||
colorByDayNight: boolean;
|
||||
dayColor: string;
|
||||
nightColor: string;
|
||||
detailed: boolean;
|
||||
}
|
||||
|
||||
@@ -26,9 +23,6 @@ export const ClockWeatherSummary = ({
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
animateIcon,
|
||||
colorByDayNight,
|
||||
dayColor,
|
||||
nightColor,
|
||||
detailed,
|
||||
}: ClockWeatherSummaryProps) => {
|
||||
const tCommon = useI18n("common");
|
||||
@@ -52,10 +46,6 @@ export const ClockWeatherSummary = ({
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
const weatherColor = colorByDayNight
|
||||
? `var(--mantine-color-${weather.current.isDay ? dayColor : nightColor}-5)`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Stack gap={detailed ? 3 : 2} align={detailed ? "flex-start" : "center"}>
|
||||
{detailed ? (
|
||||
@@ -64,9 +54,9 @@ export const ClockWeatherSummary = ({
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
style={{ ...zoomCompensatedSize(28), ...(weatherColor ? { color: weatherColor } : undefined) }}
|
||||
style={zoomCompensatedSize(28)}
|
||||
/>
|
||||
<Text size="sm" fw={600} c={weatherColor}>
|
||||
<Text size="sm" fw={600}>
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
@@ -78,9 +68,9 @@ export const ClockWeatherSummary = ({
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
style={{ ...zoomCompensatedSize(26), ...(weatherColor ? { color: weatherColor } : undefined) }}
|
||||
style={zoomCompensatedSize(26)}
|
||||
/>
|
||||
<Text size="xs" c={weatherColor ?? "dimmed"}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconMovieOff, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface MediaRequestsEmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
integrationIds: string[];
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
export const MediaRequestsEmptyState = ({
|
||||
title,
|
||||
description,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: MediaRequestsEmptyStateProps) => {
|
||||
const tSearch = useI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
|
||||
<ThemeIcon variant="light" size="xl" radius="xl">
|
||||
<IconMovieOff />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={600} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" ta="center" maw="28ch">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isEditMode ? (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconSearch size="var(--mantine-font-size-md)" />}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
{tSearch("action.search.label")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { MediaRequestsEmptyState } from "../empty-state";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
@@ -30,7 +30,6 @@ export default function MediaServerWidget({
|
||||
options,
|
||||
width,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const t = useI18n("widget.mediaRequests-requestList");
|
||||
const interactIntegrationIds = new Set(
|
||||
useIntegrationsWithInteractAccess()
|
||||
.filter(({ id }) => integrationIds.includes(id))
|
||||
@@ -49,16 +48,7 @@ export default function MediaServerWidget({
|
||||
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) {
|
||||
return (
|
||||
<MediaRequestsEmptyState
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
integrationIds={integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { MediaRequestsEmptyState } from "../empty-state";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
@@ -54,16 +54,8 @@ export default function MediaServerWidget({
|
||||
requestStats.users.length === 0 &&
|
||||
requestStats.stats.length === 0 &&
|
||||
requestStats.failedIntegrations.length === 0
|
||||
) {
|
||||
return (
|
||||
<MediaRequestsEmptyState
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
integrationIds={integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
throw new NoIntegrationDataError();
|
||||
|
||||
const data = [
|
||||
{
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
margin-inline: calc(var(--mantine-spacing-md) * -1);
|
||||
margin-bottom: calc(var(--mantine-spacing-md) * -1);
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
padding-bottom: max(var(--mantine-spacing-sm), env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { IntegrationProvider, useSession } from "@homarr/auth/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -674,7 +674,16 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
|
||||
const footer = (
|
||||
<Box className={classes.footer}>
|
||||
<ModalFormFooter onCancel={actions.closeModal} form={widgetFormId} loading={isSubmitting} />
|
||||
<Group justify="end" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -21,10 +21,14 @@ export default function SpeedtestTrackerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
|
||||
const [advancedRange, setAdvancedRange] = useState("24");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
|
||||
|
||||
const combined = useMemo(
|
||||
@@ -32,24 +36,98 @@ export default function SpeedtestTrackerWidget({
|
||||
[successfulDashboards],
|
||||
);
|
||||
|
||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||||
const rangeHours = isAdvanced ? Number(advancedRange) : 12;
|
||||
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
|
||||
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
|
||||
const showLatestResult = options.showLatestResult;
|
||||
const showStats = options.showStats;
|
||||
const showRecentResults = options.showRecentResults;
|
||||
const showPingGraph = options.showPingGraph;
|
||||
const showLatestResult = isAdvanced || options.showLatestResult;
|
||||
const showStats = isAdvanced || options.showStats;
|
||||
const showRecentResults = isAdvanced || options.showRecentResults;
|
||||
const showPingGraph = isAdvanced || options.showPingGraph;
|
||||
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
|
||||
const hasChart = showRecentResults && recentFiltered.length > 0;
|
||||
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
const noSectionsEnabled =
|
||||
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
const advancedSectionWidth = width >= 720 ? width / 2 : width;
|
||||
|
||||
const latest = showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection result={combined.latestResult} width={width} compactSurface />
|
||||
<LatestResultSection
|
||||
result={combined.latestResult}
|
||||
width={isAdvanced ? advancedSectionWidth : width}
|
||||
compactSurface={!isAdvanced}
|
||||
/>
|
||||
);
|
||||
const averages = showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={width} compactSurface />
|
||||
<AveragesSection
|
||||
stats={combined.stats}
|
||||
width={isAdvanced ? advancedSectionWidth : width}
|
||||
compactSurface={!isAdvanced}
|
||||
/>
|
||||
);
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
|
||||
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
|
||||
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={advancedRange}
|
||||
onChange={setAdvancedRange}
|
||||
data={[
|
||||
{ value: "12", label: t("range.twelveHours") },
|
||||
{ value: "24", label: t("range.oneDay") },
|
||||
{ value: "0", label: t("range.lastThirty") },
|
||||
]}
|
||||
/>
|
||||
{successfulDashboards.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<SimpleGrid cols={sourceColumns} spacing="md">
|
||||
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
|
||||
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
|
||||
const sourceHasChart = sourceRecent.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{integrationName}
|
||||
</Text>
|
||||
{sourceHasStats && (
|
||||
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
|
||||
{dashboard.latestResult && (
|
||||
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
|
||||
)}
|
||||
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{sourceHasChart && (
|
||||
<Box h={Math.max(260, Math.min(420, height / 2))}>
|
||||
<RecentResultsSection results={sourceRecent} showPingGraph />
|
||||
</Box>
|
||||
)}
|
||||
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
|
||||
icon: IconSpeedboat,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
@@ -21,43 +21,9 @@ export const CombinedNetworkTrafficChart = ({
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = usageOverTime.map((usage, index) => ({
|
||||
index,
|
||||
up: usage.up,
|
||||
down: usage.down,
|
||||
}));
|
||||
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
|
||||
const tooltipLabel = (index: number) => {
|
||||
const point = usageOverTime[index];
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
bg="orange.5"
|
||||
w={8}
|
||||
h={8}
|
||||
style={{ borderRadius: 99, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs">
|
||||
{t("up")}: {formatByteRate(Math.round(point?.up ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
bg="yellow.5"
|
||||
w={8}
|
||||
h={8}
|
||||
style={{ borderRadius: 99, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs">
|
||||
{t("down")}: {formatByteRate(Math.round(point?.down ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CommonChart
|
||||
data={chartData}
|
||||
@@ -72,7 +38,28 @@ export const CombinedNetworkTrafficChart = ({
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipLabel={tooltipLabel}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Stack gap={0}>
|
||||
{payload.map((payloadData) => (
|
||||
<Group key={payloadData.key} gap={4}>
|
||||
<Box bg={payloadData.color} w={10} h={10} style={{ borderRadius: 99 }}></Box>
|
||||
<Text c="dimmed" size="xs">
|
||||
{payloadData.value === undefined ? (
|
||||
<>N/A</>
|
||||
) : (
|
||||
formatByteRate(Math.round(Number(payloadData.value)))
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type {
|
||||
CSSProperties,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { AreaChartSeries } from "@mantine/charts";
|
||||
import { AreaChart, LineChart } from "@mantine/charts";
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||
import type { YAxisProps } from "recharts";
|
||||
import type { TooltipProps, YAxisProps } from "recharts";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
@@ -33,7 +19,7 @@ export const CommonChart = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
labelDisplayMode,
|
||||
tooltipLabel,
|
||||
tooltipProps,
|
||||
yAxisProps,
|
||||
lastValue,
|
||||
chartType = "line",
|
||||
@@ -45,10 +31,7 @@ export const CommonChart = ({
|
||||
title: ReactNode;
|
||||
icon: TablerIcon;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
// What to show on hover. Defaults to lastValue when omitted. Pass a function to reflect
|
||||
// the datum under the cursor as it moves across multi-point data (e.g. memory's
|
||||
// used/available at that point), instead of always showing the latest value.
|
||||
tooltipLabel?: ReactNode | ((index: number) => ReactNode);
|
||||
tooltipProps?: TooltipProps<number, any>;
|
||||
yAxisProps?: Omit<YAxisProps, "ref">;
|
||||
lastValue?: string;
|
||||
chartType?: "line" | "area";
|
||||
@@ -62,80 +45,63 @@ export const CommonChart = ({
|
||||
const ref = useMergedRef(elementSizeRef, hoverRef);
|
||||
|
||||
const opacity = board.opacity / 100;
|
||||
let backgroundColor =
|
||||
colorScheme === "dark"
|
||||
? `rgba(57, 57, 57, ${opacity})`
|
||||
: `rgba(246, 247, 248, ${opacity})`;
|
||||
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
|
||||
const cardStyle: CSSProperties = { overflow: "hidden" };
|
||||
const chartRootStyle: CSSProperties = {
|
||||
padding: 5,
|
||||
borderRadius: theme.radius[board.itemRadius],
|
||||
};
|
||||
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
|
||||
if (advanced) {
|
||||
backgroundColor =
|
||||
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
cardStyle.border =
|
||||
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
chartRootStyle.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
|
||||
const showIcon =
|
||||
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||
const showText =
|
||||
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
|
||||
|
||||
// Track which data point the cursor is over so the tooltip can reflect that point
|
||||
// instead of always showing the latest value while scrubbing across the chart.
|
||||
const [hoveredIndex, setHoveredIndex] = useState(data.length - 1);
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (data.length <= 1) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
const ratio = (event.clientX - rect.left) / rect.width;
|
||||
const index = Math.round(ratio * (data.length - 1));
|
||||
setHoveredIndex(Math.min(data.length - 1, Math.max(0, index)));
|
||||
};
|
||||
|
||||
// Clamp on read rather than syncing via an effect - handles data growing/shrinking
|
||||
// between renders (e.g. history resetting on integration change) without ever
|
||||
// handing a stale or out-of-range index to tooltipLabel.
|
||||
const clampedHoveredIndex = Math.max(
|
||||
0,
|
||||
Math.min(hoveredIndex, data.length - 1),
|
||||
);
|
||||
const resolvedTooltipLabel =
|
||||
typeof tooltipLabel === "function"
|
||||
? tooltipLabel(clampedHoveredIndex)
|
||||
: (tooltipLabel ?? lastValue);
|
||||
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
|
||||
|
||||
return (
|
||||
<Tooltip.Floating
|
||||
label={resolvedTooltipLabel}
|
||||
disabled={data.length <= 1 || !resolvedTooltipLabel}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
h={"100%"}
|
||||
pos={"relative"}
|
||||
style={cardStyle}
|
||||
p={0}
|
||||
bg={backgroundColor}
|
||||
radius={board.itemRadius}
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
{data.length > 1 && height > 40 && !hovered && (
|
||||
<Group
|
||||
pos={"absolute"}
|
||||
top={0}
|
||||
left={0}
|
||||
p={8}
|
||||
pt={6}
|
||||
gap={5}
|
||||
wrap={"nowrap"}
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
align="center"
|
||||
>
|
||||
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
|
||||
{data.length > 1 && height > 40 && !hovered && (
|
||||
<Group
|
||||
pos={"absolute"}
|
||||
top={0}
|
||||
left={0}
|
||||
p={8}
|
||||
pt={6}
|
||||
gap={5}
|
||||
wrap={"nowrap"}
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
align="center"
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{lastValue && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
|
||||
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
|
||||
<Text size="xs" fw={600}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
@@ -144,85 +110,43 @@ export const CommonChart = ({
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{lastValue && (
|
||||
<Text
|
||||
c={"dimmed"}
|
||||
size={height > 100 ? "md" : "xs"}
|
||||
lineClamp={1}
|
||||
>
|
||||
{advanced && lastValue && (
|
||||
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{advanced &&
|
||||
data.length > 1 &&
|
||||
height > 0 &&
|
||||
height <= 40 &&
|
||||
lastValue &&
|
||||
!hovered && (
|
||||
<Center
|
||||
pos="absolute"
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
>
|
||||
<Text size="xs" fw={600}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text
|
||||
c={"dimmed"}
|
||||
size={height > 100 ? "md" : "xs"}
|
||||
fw={"bold"}
|
||||
ta="center"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{advanced && lastValue && (
|
||||
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ChartComponent
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
h={"100%"}
|
||||
series={series}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withXAxis={false}
|
||||
withYAxis={false}
|
||||
withDots={false}
|
||||
bg={backgroundColor}
|
||||
styles={{ root: chartRootStyle }}
|
||||
withTooltip={false}
|
||||
yAxisProps={yAxisProps}
|
||||
fillOpacity={chartType === "area" ? 0.3 : undefined}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Tooltip.Floating>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ChartComponent
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
h={"100%"}
|
||||
series={series}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withXAxis={false}
|
||||
withYAxis={false}
|
||||
withDots={false}
|
||||
bg={backgroundColor}
|
||||
styles={{ root: chartRootStyle }}
|
||||
tooltipAnimationDuration={200}
|
||||
tooltipProps={{
|
||||
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
|
||||
// (needed to stop the chart line itself bleeding past its rounded corners).
|
||||
portal: typeof document !== "undefined" ? document.body : undefined,
|
||||
...tooltipProps,
|
||||
}}
|
||||
withTooltip={height >= 64}
|
||||
yAxisProps={yAxisProps}
|
||||
fillOpacity={chartType === "area" ? 0.3 : undefined}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
@@ -26,16 +27,25 @@ export const SystemResourceCPUChart = ({
|
||||
title={invariantTechnicalLabels.cpu}
|
||||
icon={IconCpu}
|
||||
lastValue={
|
||||
cpuUsageOverTime.length > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
`${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%`
|
||||
: undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
|
||||
}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
yAxisProps={{ domain: [0, 100] }}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipLabel={(index) => `${Math.round(cpuUsageOverTime[index] ?? 0)}%`}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{value.toFixed(0)}%
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconDeviceDesktop } from "@tabler/icons-react";
|
||||
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
@@ -26,16 +27,25 @@ export const SystemResourceGPUChart = ({
|
||||
title={invariantTechnicalLabels.gpu}
|
||||
icon={IconDeviceDesktop}
|
||||
lastValue={
|
||||
gpuUsageOverTime.length > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
`${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%`
|
||||
: undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
gpuUsageOverTime.length > 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined
|
||||
}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
yAxisProps={{ domain: [0, 100] }}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipLabel={(index) => `${Math.round(gpuUsageOverTime[index] ?? 0)}%`}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{value.toFixed(0)}%
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
|
||||
import { formatBytesPair } from "@homarr/common";
|
||||
@@ -19,33 +20,15 @@ export const SystemResourceMemoryChart = ({
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = memoryUsageOverTime.map((usage, index) => ({
|
||||
index,
|
||||
usage,
|
||||
}));
|
||||
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
|
||||
const percentageUsed =
|
||||
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
memoryUsageOverTime[memoryUsageOverTime.length - 1]! /
|
||||
totalCapacityInBytes
|
||||
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
|
||||
: undefined;
|
||||
|
||||
const tooltipLabel = (index: number) => {
|
||||
const used = memoryUsageOverTime[index] ?? 0;
|
||||
const memory = formatBytesPair(Math.round(used), totalCapacityInBytes);
|
||||
const percent =
|
||||
totalCapacityInBytes > 0
|
||||
? Math.round((used / totalCapacityInBytes) * 100)
|
||||
: 0;
|
||||
return t("memoryTooltip", {
|
||||
used: memory.used,
|
||||
available: memory.available,
|
||||
percent,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CommonChart
|
||||
data={chartData}
|
||||
@@ -56,13 +39,22 @@ export const SystemResourceMemoryChart = ({
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
|
||||
lastValue={
|
||||
percentageUsed !== undefined
|
||||
? `${Math.round(percentageUsed * 100)}%`
|
||||
: undefined
|
||||
}
|
||||
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
tooltipLabel={tooltipLabel}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{memory.used} / {memory.available} (
|
||||
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
|
||||
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
@@ -38,9 +39,18 @@ export const NetworkTrafficChart = ({
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipLabel={(index) =>
|
||||
formatByteRate(Math.round(usageOverTime[index] ?? 0))
|
||||
}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{formatByteRate(Math.round(value))}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26,
|
||||
let animationClass = "";
|
||||
if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code);
|
||||
return (
|
||||
<span className={`weather-anim-wrapper ${animationClass}`} data-is-day={isDay === false ? "false" : "true"}>
|
||||
<span className={`weather-anim-wrapper ${animationClass}`}>
|
||||
<WeatherIcon code={code} isDay={isDay} size={size} style={style} />
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user