Compare commits

...
76 changed files with 694 additions and 220 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/` - Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/` - App routes: `apps/nextjs/src/app/[locale]/`
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`. When a change affects setup, usage, troubleshooting, or the accuracy of existing docs, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+13 -5
View File
@@ -1,13 +1,21 @@
--- ---
name: documentation-sync name: documentation-sync
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand. description: Keep Homarr's Docusaurus documentation useful and accurate. Use when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate.
--- ---
# Documentation Sync # Documentation Sync
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together. Update `apps/docs/` when users need new or corrected guidance to configure, use, or troubleshoot Homarr. Keep documentation straight to the point.
## Map the change ## Decide whether documentation is needed
Identify the user action, decision, prerequisite, limitation, or troubleshooting step that changes. Update the relevant docs when that information is missing or inaccurate. If nothing users need to know changes, leave the docs untouched.
Routine visual fixes such as icon sizing, spacing, alignment, responsive layout, and animation usually need no documentation. Describe them in the PR when useful for review. Document a visual change only when it changes a user workflow, setting, or meaningful limitation.
Write the minimum information needed to complete the user's task. Inspect adjacent pages and the docs types before editing.
## Map changes that need documentation
| Code change | Documentation target | | Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- | | ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,8 +52,8 @@ For a widget:
## Completion criteria ## Completion criteria
1. Describe the shipped behavior, not an implementation plan. 1. Every addition helps a user configure, use, or troubleshoot Homarr; omit incidental rendering and implementation details.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code. 2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance. 3. Correct affected guidance and remove superseded instructions. A code change alone does not require a docs edit.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files. 4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; Docusaurus checks them strictly. 5. Treat broken links and anchors as failures; Docusaurus checks them strictly.
@@ -1,4 +1,4 @@
interface: interface:
display_name: "Homarr Documentation Sync" display_name: "Homarr Documentation Sync"
short_description: "Keep user-facing docs aligned with code" short_description: "Keep user docs concise, useful, and accurate"
default_prompt: "Use $documentation-sync to map this Homarr code change to the required documentation updates." default_prompt: "Use $documentation-sync to decide whether this Homarr change needs user guidance and update only what users need to know."
+1 -1
View File
@@ -10,7 +10,7 @@
- [ ] Pull request targets the intended base branch (`dev` by default; `release/v2` for v2 release work) - [ ] Pull request targets the intended base branch (`dev` by default; `release/v2` for v2 release work)
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (for example, `x`, `y`, `i`, or abbreviations) - [ ] No shorthand variable names are used (for example, `x`, `y`, `i`, or abbreviations)
- [ ] User-facing changes include matching documentation updates under `apps/docs` - [ ] Changes to setup, usage, troubleshooting, or existing guidance include relevant documentation updates under `apps/docs` when needed
- [ ] When using AI, no temporary files are checked in and the code style follows the rest of the project - [ ] When using AI, no temporary files are checked in and the code style follows the rest of the project
**When contributing a new integration or widget:** **When contributing a new integration or widget:**
+2 -2
View File
@@ -59,7 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`). The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
When modifying user-facing code, you MUST also update the corresponding documentation: Update documentation when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate. Follow `.agents/skills/documentation-sync/SKILL.md` to decide whether an update is needed and keep it concise. For changes that need documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` - New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` - New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -99,6 +99,6 @@ When modifying user-facing code, you MUST also update the corresponding document
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`. Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities - `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — required user-facing documentation updates - `documentation-sync` — concise, relevant user guidance
- `mcp-integration` — safe tRPC-to-MCP exposure - `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring - `homarr-custom-widget` — safe Custom JSX v2 authoring
+2 -1
View File
@@ -12,7 +12,8 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} /> <WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions. check from the tile's edit actions. Very small tiles shorten the title and description to leave room for the icon;
enlarge the tile to show more text.
### Adding the widget ### Adding the widget
+2 -1
View File
@@ -2304,7 +2304,8 @@
"traefik", "traefik",
"customApi", "customApi",
"assistant", "assistant",
"wud" "wud",
"llamacpp"
] ]
}, },
"options": { "options": {
@@ -27,7 +27,7 @@ import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-s
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout"; import { getLogicalTrackSize } from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { useBoardContentScale } from "~/components/board/layout/scaled-board-canvas";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context"; import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -148,7 +148,7 @@ const LoadedBoardItemContent = ({
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null); const previewEntranceFrameRef = useRef<number | null>(null);
const board = useRequiredBoard(); const board = useRequiredBoard();
const boardCanvasScale = useBoardCanvasScale(); const boardContentScale = useBoardContentScale();
const t = useI18n(); const t = useI18n();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const settings = useSettings(); const settings = useSettings();
@@ -163,8 +163,8 @@ const LoadedBoardItemContent = ({
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t); const widgetName = getWidgetName(item.kind, t);
const previewDimensions = useMemo( const previewDimensions = useMemo(
() => ({ width, height, scale: boardCanvasScale }), () => ({ width, height, scale: boardContentScale }),
[boardCanvasScale, height, width], [boardContentScale, height, width],
); );
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`; const advancedViewId = `advanced-focus-${item.id}`;
@@ -314,7 +314,7 @@ const LoadedBoardItemContent = ({
item={item} item={item}
width={width} width={width}
height={height} height={height}
displayScale={boardCanvasScale} displayScale={boardContentScale}
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef} widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"} displayMode={isAdvanced ? "advanced" : "compact"}
@@ -17,7 +17,10 @@
.canvas { .canvas {
position: absolute; position: absolute;
inset: 0 auto auto 0; inset: 0 auto auto 0;
}
.canvas,
.contentScale {
/* /*
* Keep dashboard UI at its normal readable density while the fixed canvas * Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser * is fitted below 100%. Above 100%, the canvas still behaves like browser
@@ -8,6 +8,10 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css"; import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1); const BoardCanvasScaleContext = createContext(1);
// Widget content can also be scaled by containers inside the board canvas.
const BoardContentScaleContext = createContext(1);
export const BoardContentScaleProvider = BoardContentScaleContext.Provider;
export const useBoardContentScale = () => useContext(BoardContentScaleContext);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext); export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -86,7 +90,9 @@ export const ScaledBoardCanvas = ({
zoom: scale, zoom: scale,
}} }}
> >
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider> <BoardCanvasScaleContext.Provider value={scale}>
<BoardContentScaleProvider value={scale}>{children}</BoardContentScaleProvider>
</BoardCanvasScaleContext.Provider>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@@ -18,7 +18,13 @@ import {
getReadonlyCanvasAttributes, getReadonlyCanvasAttributes,
normalizeGridPlacement, normalizeGridPlacement,
} from "~/components/board/layout"; } from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import {
BoardContentScaleProvider,
calculateBoardUiScale,
useBoardCanvasScale,
useBoardContentScale,
} from "~/components/board/layout/scaled-board-canvas";
import canvasClasses from "~/components/board/layout/scaled-board-canvas.module.css";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry"; import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions"; import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -50,6 +56,7 @@ export const SectionGrid = ({
}: SectionGridProps) => { }: SectionGridProps) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale(); const canvasScale = useBoardCanvasScale();
const parentContentScale = useBoardContentScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus(); const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry(); const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null); const editorHostRef = useRef<HTMLDivElement>(null);
@@ -144,10 +151,16 @@ export const SectionGrid = ({
const viewportRowCount = const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount); viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1; const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels. let effectiveParentContentScale = effectiveCanvasScale;
// Convert that height so the label reserves the same space at every board scale. if (Number.isFinite(parentContentScale) && parentContentScale > 0) {
effectiveParentContentScale = parentContentScale;
}
// Compensate every ancestor container while retaining the board's enlargement above 100%.
const parentUiScale =
(calculateBoardUiScale(effectiveCanvasScale) * effectiveCanvasScale) / effectiveParentContentScale;
// Match the label's Mantine height in its parent's logical coordinate system.
const collapsibleHeaderInset = const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0; section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT * parentUiScale : 0;
const fullGridWidth = getLogicalGridSize(columnCount); const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount); const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth; const logicalWidth = fullGridWidth;
@@ -172,7 +185,8 @@ export const SectionGrid = ({
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property) // both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value", // is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants. // silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale; const combinedUiScale = parentUiScale / containerContentScale;
const contentScale = effectiveParentContentScale * containerContentScale;
// A collapsed container's compact coordinates are display-only. Its own // A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it. // nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id); const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -282,21 +296,23 @@ export const SectionGrid = ({
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit} onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
> >
<Box <Box
className={classes.staticGrid} className={combineClasses(classes.staticGrid, canvasClasses.contentScale)}
style={ style={
{ {
width: fullGridWidth, width: fullGridWidth,
height: fullGridHeight, height: fullGridHeight,
zoom: containerContentScale, zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined, margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}), "--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties } as CSSProperties
} }
data-grid-section-id={section.id} data-grid-section-id={section.id}
data-kind={section.kind} data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined} data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
> >
<SectionContent /> <BoardContentScaleProvider value={contentScale}>
<SectionContent />
</BoardContentScaleProvider>
</Box> </Box>
<div ref={editorHostRef} className={classes.editorPortalHost} /> <div ref={editorHostRef} className={classes.editorPortalHost} />
</Box> </Box>
+14 -1
View File
@@ -8,6 +8,7 @@ import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale"; import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -27,7 +28,19 @@ import {
getUvCategory, getUvCategory,
} from "./display"; } from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) { export default function AirQualityWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"airQuality">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const query = clientApi.widget.airQuality.atLocation.useQuery({ const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude, latitude: options.location.latitude,
longitude: options.location.longitude, longitude: options.location.longitude,
+11 -2
View File
@@ -11,6 +11,7 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations"; import type { AnchorNotePermission } from "@homarr/integrations";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -37,10 +38,18 @@ const isForbiddenError = (error: unknown): boolean => {
export default function AnchorNoteWidget({ export default function AnchorNoteWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"anchorNote">) { }: WidgetComponentProps<"anchorNote">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.anchorNote"); const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim(); const noteId = options.noteId.trim();
if (!noteId) { if (!noteId) {
+9 -1
View File
@@ -1,8 +1,16 @@
.appText {
min-width: 0;
min-height: 0;
flex-shrink: 1;
overflow-wrap: anywhere;
overflow: hidden;
}
.appIcon { .appIcon {
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
overflow: auto; overflow: auto;
flex: 1 !important; flex: 1;
object-fit: contain; object-fit: contain;
scale: 0.8; scale: 0.8;
transition: scale 0.2s ease-in-out; transition: scale 0.2s ease-in-out;
+44 -11
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { CSSProperties, PropsWithChildren } from "react";
import { Fragment, Suspense } from "react"; import { Fragment, Suspense } from "react";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react"; import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
@@ -12,6 +12,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui"; import { MaskedOrNormalImage } from "@homarr/ui";
import { getWidgetDisplayScale } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -21,7 +22,14 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot"; import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator"; import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) { export default function AppWidget({
options,
isEditMode,
height,
width,
displayScale = 1,
displayMode,
}: WidgetComponentProps<"app">) {
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const settings = useSettings(); const settings = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -33,8 +41,31 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />; if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />; if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100; // Readable board tokens stay fixed on screen, but must yield space to the icon
// when the tile itself becomes small. Below 100px, scale the whole composition.
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const maximumContentScale = Math.min(width, height) / 100;
let uiScale = "var(--board-canvas-ui-scale, 1)";
if (displayMode === "advanced") uiScale = "1";
const textSize = `min(calc(14px * ${uiScale}), ${14 * maximumContentScale}px)`;
const spacing = `min(calc(12px * ${uiScale}), ${12 * maximumContentScale}px)`;
const rowGap = `min(calc(6px * ${uiScale}), ${6 * maximumContentScale}px)`;
const isColumnLayout = options.layout.startsWith("column"); const isColumnLayout = options.layout.startsWith("column");
const isTiny = Math.min(width, height) * scale < 100;
const textStyle: CSSProperties = {};
let titleLineClamp: number | undefined;
let descriptionLineClamp = 4;
if (isTiny) {
titleLineClamp = 2;
descriptionLineClamp = 2;
textStyle.flexShrink = 1;
textStyle.maxWidth = "50%";
textStyle.maxHeight = "100%";
if (isColumnLayout) {
textStyle.maxWidth = "100%";
textStyle.maxHeight = "50%";
}
}
return ( return (
<Box h="100%" w="100%" pos="relative"> <Box h="100%" w="100%" pos="relative">
@@ -44,22 +75,22 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode} enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
> >
<Flex <Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)} className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%" h="100%"
w="100%" w="100%"
direction={options.layout} direction={options.layout}
justify="center" justify="center"
align="center" align="center"
gap={isColumnLayout ? 0 : "sm"} style={{ padding: spacing, gap: isColumnLayout ? 0 : rowGap }}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined} onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
> >
<Stack gap={0}> <Stack gap={0} className={classes.appText} style={textStyle}>
{options.showTitle && ( {options.showTitle && (
<Text <Text
className="app-title" className="app-title"
fw={700} fw={700}
size={isTiny ? rem(8) : "sm"} lineClamp={titleLineClamp}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined} ta={isColumnLayout ? "center" : undefined}
> >
{app.name} {app.name}
@@ -68,10 +99,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{options.descriptionDisplayMode === "normal" && ( {options.descriptionDisplayMode === "normal" && (
<Text <Text
className="app-description" className="app-description"
size={isTiny ? rem(8) : "sm"} style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined} ta={isColumnLayout ? "center" : undefined}
c="dimmed" c="dimmed"
lineClamp={4} lineClamp={descriptionLineClamp}
> >
{app.description?.split("\n").map((line, index) => ( {app.description?.split("\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -88,9 +119,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
alt={app.name} alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")} className={combineClasses(classes.appIcon, "app-icon")}
style={{ style={{
flex: isColumnLayout ? "1 0 25%" : "0 0 50%",
height: "100%", height: "100%",
width: "100%", width: "100%",
minWidth: "20%", minWidth: 0,
minHeight: 0,
maxWidth: isColumnLayout ? undefined : "50%", maxWidth: isColumnLayout ? undefined : "50%",
}} }}
/> />
@@ -7,15 +7,24 @@ import { getIconUrl } from "@homarr/definitions";
import { useByteFormatter } from "@homarr/settings"; import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({ export default function ArchiveTeamWarriorWidget({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"archiveTeamWarrior">) { }: WidgetComponentProps<"archiveTeamWarrior">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0]; const integrationId = integrationIds[0];
if (!integrationId) { if (!integrationId) {
@@ -8,6 +8,7 @@ import { formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useByteFormatter } from "@homarr/settings"; import { useByteFormatter } from "@homarr/settings";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -42,12 +43,12 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? []; const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />; if (!response) return <WidgetEmptyState />;
let responsiveWidth = width; const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
let responsiveHeight = height; width,
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { height,
responsiveWidth *= displayScale; displayScale,
responsiveHeight *= displayScale; displayMode,
} });
const summary = ( const summary = (
<AudioStatsContent <AudioStatsContent
+11 -2
View File
@@ -9,6 +9,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css"; import classes from "./component.module.css";
@@ -58,10 +59,18 @@ const iconSizeByWidth = [
export default function BazarrWidget({ export default function BazarrWidget({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"bazarr">) { }: WidgetComponentProps<"bazarr">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.bazarr"); const t = useI18n("widget.bazarr");
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery( const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" }, { integrationId: integrationIds[0] ?? "" },
@@ -37,6 +37,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css"; import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -456,10 +457,17 @@ export default function BeszelSystemGridWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"beszelSystemGrid">) { }: WidgetComponentProps<"beszelSystemGrid">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.beszel"); const t = useI18n("widget.beszel");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -520,7 +528,9 @@ export default function BeszelSystemGridWidget({
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`, gridTemplateRows: scrollEnabled
? `repeat(${rows}, ${minimumCellHeight / getWidgetDisplayScale({ displayScale, displayMode })}px)`
: `repeat(${rows}, 1fr)`,
gap: size.gap, gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden", overflow: scrollEnabled ? "auto" : "hidden",
}} }}
@@ -13,6 +13,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css"; import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -33,6 +34,7 @@ export default function BeszelSystemStatsWidget({
width, width,
height, height,
displayScale = 1, displayScale = 1,
displayMode,
boardId, boardId,
itemId, itemId,
setOptions, setOptions,
@@ -71,12 +73,12 @@ export default function BeszelSystemStatsWidget({
{ value: "1w", label: t("period.oneWeek") }, { value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") }, { value: "30d", label: t("period.thirtyDays") },
]; ];
let responsiveWidth = width; const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
let responsiveHeight = height; width,
if (Number.isFinite(displayScale) && displayScale > 0) { height,
responsiveWidth *= displayScale; displayScale,
responsiveHeight *= displayScale; displayMode,
} });
useWidgetRuntimeQueries( useWidgetRuntimeQueries(
widgetRuntimeRef, widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m" selectedSystem && options.timePeriod !== "1m"
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings"; import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table"; import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -112,12 +113,16 @@ export default function BeszelSystemTableWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width: logicalWidth,
boardId, boardId,
itemId, itemId,
setOptions, setOptions,
displayMode, displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"beszelSystemTable">) { }: WidgetComponentProps<"beszelSystemTable">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.beszelSystemTable"); const t = useI18n("widget.beszelSystemTable");
const tBeszel = useI18n("widget.beszel"); const tBeszel = useI18n("widget.beszel");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -25,6 +25,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -68,12 +69,12 @@ export default function BookmarksWidget({
}); });
const data = [...configuredItems, ...legacyItems]; const data = [...configuredItems, ...legacyItems];
let responsiveWidth = width; const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
let responsiveHeight = height; width,
if (!advanced && Number.isFinite(displayScale) && displayScale > 0) { height,
responsiveWidth *= displayScale; displayScale,
responsiveHeight *= displayScale; displayMode,
} });
const contentHeight = Math.max(0, responsiveHeight - (options.title.length > 0 ? 36 : 0)); const contentHeight = Math.max(0, responsiveHeight - (options.title.length > 0 ? 36 : 0));
let layoutWidth = responsiveWidth; let layoutWidth = responsiveWidth;
if (options.layout === "gridHorizontal") layoutWidth = width; if (options.layout === "gridHorizontal") layoutWidth = width;
+20 -2
View File
@@ -13,6 +13,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import actionTargetClasses from "../common/action-target.module.css"; import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -68,7 +69,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void; setMonth: (date: Date) => void;
} }
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => { const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
displayScale,
}: FetchCalendarProps) => {
const input = { const input = {
integrationIds, integrationIds,
month: month.getMonth() + 1, month: month.getMonth() + 1,
@@ -111,11 +120,13 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth} setMonth={setMonth}
options={options} options={options}
displayMode={displayMode} displayMode={displayMode}
displayScale={displayScale}
/> />
); );
}; };
interface CalendarBaseProps { interface CalendarBaseProps {
displayScale?: number;
isEditMode: boolean; isEditMode: boolean;
events: CalendarEventWithSource[]; events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[]; failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
@@ -135,13 +146,20 @@ const CalendarBase = ({
setMonth, setMonth,
options, options,
displayMode, displayMode,
displayScale,
}: CalendarBaseProps) => { }: CalendarBaseProps) => {
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings(); const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius]; const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize(); const { ref, width: logicalWidth, height: logicalHeight } = useElementSize();
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isSmall = width < 256; const isSmall = width < 256;
const normalizedEvents = useMemo( const normalizedEvents = useMemo(
+14 -1
View File
@@ -4,6 +4,7 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat"; import advancedFormat from "dayjs/plugin/advancedFormat";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { AdvancedClockView } from "./advanced-view"; import { AdvancedClockView } from "./advanced-view";
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format"; import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
@@ -13,7 +14,19 @@ import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
dayjs.extend(advancedFormat); dayjs.extend(advancedFormat);
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) { export default function ClockWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"clock">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat); const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const time = useCurrentTime({ showSeconds }); const time = useCurrentTime({ showSeconds });
@@ -0,0 +1,22 @@
interface WidgetDisplay {
displayScale?: number;
displayMode?: "compact" | "advanced";
}
export const getWidgetDisplayScale = ({ displayScale = 1, displayMode }: WidgetDisplay): number => {
// Advanced surfaces are outside the scaled board, even when their source tile is not.
if (displayMode === "advanced" || !Number.isFinite(displayScale) || displayScale <= 0) return 1;
return displayScale;
};
/** Visible dimensions for density, breakpoints, and content budgets.
* Keep the original logical dimensions for canvas/SVG geometry and CSS pixel sizes.
*/
export const getWidgetLayoutSize = ({
width,
height,
...display
}: WidgetDisplay & { width: number; height: number }) => {
const scale = getWidgetDisplayScale(display);
return { width: width * scale, height: height * scale };
};
+6 -1
View File
@@ -5,6 +5,7 @@ import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -16,9 +17,13 @@ import type { InstanceData } from "./types";
export default function CoolifyWidget({ export default function CoolifyWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
displayMode, displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"coolify">) { }: WidgetComponentProps<"coolify">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.coolify"); const t = useI18n("widget.coolify");
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
+2
View File
@@ -175,8 +175,10 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
isEditMode: boolean; isEditMode: boolean;
displayMode?: "compact" | "advanced"; displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void; setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
/** Logical pixels for rendering geometry. Use getWidgetLayoutSize for responsive decisions. */
width: number; width: number;
height: number; height: number;
/** Board scale; advanced surfaces render outside the board at scale 1. */
displayScale?: number; displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef; widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>; widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
@@ -38,6 +38,7 @@ import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import actionTargetClasses from "../../common/action-target.module.css"; import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css"; import classes from "./component.module.css";
import TimerPopover from "./timer-popover"; import TimerPopover from "./timer-popover";
@@ -58,9 +59,12 @@ export default function DnsHoleControlsWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width: logicalWidth,
displayMode, displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<typeof widgetKind>) { }: WidgetComponentProps<typeof widgetKind>) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const board = useRequiredBoard(); const board = useRequiredBoard();
// DnsHole integrations with interaction permissions // DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess() const integrationsWithInteractions = useIntegrationsWithInteractAccess()
@@ -29,6 +29,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { widgetKind } from "."; import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition"; import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
@@ -38,9 +39,18 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({ export default function DnsHoleSummaryWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
height, height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) { }: WidgetComponentProps<typeof widgetKind>) {
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds, integrationIds,
}); });
@@ -67,7 +77,14 @@ export default function DnsHoleSummaryWidget({
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}> <SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? ( {data.length > 0 ? (
stats.map((item) => ( stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} /> <StatCard
displayScale={scale}
key={item.color}
item={item}
usePiHoleColors={options.usePiHoleColors}
data={data}
t={t}
/>
)) ))
) : ( ) : (
<Stack <Stack
@@ -171,13 +188,15 @@ interface StatItem {
} }
interface StatCardProps { interface StatCardProps {
displayScale: number;
item: StatItem; item: StatItem;
data: DnsHoleSummary[]; data: DnsHoleSummary[];
usePiHoleColors: boolean; usePiHoleColors: boolean;
t: TranslationFunction; t: TranslationFunction;
} }
const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => { const StatCard = ({ item, data, usePiHoleColors, t, displayScale }: StatCardProps) => {
const { ref, height, width } = useElementSize(); const { ref, height: logicalHeight, width: logicalWidth } = useElementSize();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const isLong = width > height + 20; const isLong = width > height + 20;
const canStackText = height > 32; const canStackText = height > 32;
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92); const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
+6 -1
View File
@@ -46,6 +46,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css"; import actionTargetClasses from "../common/action-target.module.css";
@@ -241,13 +242,17 @@ const useContainerAction = (action: ContainerAction) => {
export default function DockerWidget({ export default function DockerWidget({
options, options,
width, width: logicalWidth,
isEditMode, isEditMode,
displayMode, displayMode,
boardId, boardId,
itemId, itemId,
setOptions, setOptions,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"dockerContainers">) { }: WidgetComponentProps<"dockerContainers">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("docker"); const t = useI18n("docker");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const tWidget = useI18n("widget.dockerContainers"); const tWidget = useI18n("widget.dockerContainers");
+11 -2
View File
@@ -53,6 +53,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings"; import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -302,10 +303,18 @@ export default function DownloadClientsWidget({
setOptions, setOptions,
boardId, boardId,
itemId, itemId,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"downloads">) { }: WidgetComponentProps<"downloads">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { formatByteRate, formatBytes, formatBytesPair } = useByteFormatter(); const { formatByteRate, formatBytes, formatBytesPair } = useByteFormatter();
const board = useOptionalBoard(); const board = useOptionalBoard();
const { data: session } = useSession(); const { data: session } = useSession();
+11 -2
View File
@@ -25,6 +25,7 @@ import type { FirewallInterfacesSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth } from "./bandwidth"; import { calculateBandwidth } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu"; import { FirewallMenu } from "./firewall-menu";
@@ -60,11 +61,19 @@ export const hasFirewallPartialFailure = (firewallId: string, queries: readonly
export default function FirewallWidget({ export default function FirewallWidget({
integrationIds, integrationIds,
width, width: logicalWidth,
height, height: logicalHeight,
itemId, itemId,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"firewall">) { }: WidgetComponentProps<"firewall">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const [selectedFirewall, setSelectedFirewall] = useState(""); const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180); const isTiny = !isAdvanced && (width < 256 || height < 180);
@@ -8,6 +8,7 @@ import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state"; import { getUsableWidgetQueryData } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
@@ -38,6 +39,8 @@ export const ClusterHealthMonitoring = ({
integrationId, integrationId,
options, options,
width, width,
height,
displayScale,
displayMode, displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => { }: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n("widget.healthMonitoring"); const t = useI18n("widget.healthMonitoring");
@@ -68,7 +71,8 @@ export const ClusterHealthMonitoring = ({
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0; const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0; const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const isTiny = displayMode !== "advanced" && width < 256; const { width: responsiveWidth } = getWidgetLayoutSize({ width, height, displayScale, displayMode });
const isTiny = displayMode !== "advanced" && responsiveWidth < 256;
return ( return (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative"> <Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
{(isAdvanced || options.showUptime) && !isTiny && ( {(isAdvanced || options.showUptime) && !isTiny && (
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health"; import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const CpuRing = ({ export const CpuRing = ({
cpuUtilization, cpuUtilization,
@@ -17,6 +18,7 @@ export const CpuRing = ({
}) => { }) => {
return ( return (
<GaugeChart <GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu" className="health-monitoring-cpu"
aria-label={ariaLabel} aria-label={ariaLabel}
roundCaps roundCaps
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health"; import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
const CPU_TEMPERATURE_MIN_CELSIUS = 0; const CPU_TEMPERATURE_MIN_CELSIUS = 0;
const CPU_TEMPERATURE_MAX_CELSIUS = 100; const CPU_TEMPERATURE_MAX_CELSIUS = 100;
@@ -31,6 +32,7 @@ export const CpuTempRing = ({
return ( return (
<GaugeChart <GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu-temperature" className="health-monitoring-cpu-temperature"
aria-label={ariaLabel} aria-label={ariaLabel}
roundCaps roundCaps
@@ -5,6 +5,7 @@ import { IconDeviceDesktop } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health"; import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
interface GpuRingProps { interface GpuRingProps {
gpu: { gpu: {
@@ -43,6 +44,7 @@ export const GpuRing = ({ gpu, isTiny, fahrenheit, ariaLabel }: GpuRingProps) =>
multiline multiline
> >
<GaugeChart <GaugeChart
classNames={{ label: classes.gaugeLabel }}
className={`health-monitoring-gpu health-monitoring-gpu-${gpu.gpuId}`} className={`health-monitoring-gpu health-monitoring-gpu-${gpu.gpuId}`}
aria-label={ariaLabel} aria-label={ariaLabel}
roundCaps roundCaps
@@ -6,6 +6,7 @@ import { useByteFormatter } from "@homarr/settings";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health"; import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const MemoryRing = ({ export const MemoryRing = ({
available, available,
@@ -24,6 +25,7 @@ export const MemoryRing = ({
return ( return (
<Tooltip label={`${memoryUsage.memUsed.percent}%`}> <Tooltip label={`${memoryUsage.memUsed.percent}%`}>
<GaugeChart <GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-memory" className="health-monitoring-memory"
aria-label={ariaLabel} aria-label={ariaLabel}
roundCaps roundCaps
@@ -33,3 +33,12 @@
transition: none; transition: none;
} }
} }
/* Gauge labels live inside an SVG foreignObject. The SVG already scales its
* coordinate system to the compensated gauge size; compensating again makes
* the label grow as the board shrinks. */
.gaugeLabel {
--mantine-scale: 1;
--board-canvas-ui-scale: 1;
--mantine-font-size-xs: 0.75rem;
}
@@ -42,6 +42,7 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -61,6 +62,8 @@ export const SystemHealthMonitoring = ({
options, options,
integrationIds, integrationIds,
width, width,
height,
displayScale = 1,
displayMode, displayMode,
withScrollArea = true, withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => { }: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
@@ -75,7 +78,13 @@ export const SystemHealthMonitoring = ({
const { formatBytes, formatBytesPair } = useByteFormatter(); const { formatBytes, formatBytesPair } = useByteFormatter();
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256; const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const isTiny = !isAdvanced && (responsiveWidth < 256 || responsiveHeight < 160);
const showCpu = isAdvanced || options.cpu; const showCpu = isAdvanced || options.cpu;
const showMemory = isAdvanced || options.memory; const showMemory = isAdvanced || options.memory;
const showGpu = isAdvanced || options.gpu; const showGpu = isAdvanced || options.gpu;
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui"; import { iconSizes } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks"; import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -45,12 +46,20 @@ const statusPresentation = {
export default function IndexerManagerWidget({ export default function IndexerManagerWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
height, height: logicalHeight,
isEditMode, isEditMode,
widgetRuntimeRef, widgetRuntimeRef,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"indexerManager">) { }: WidgetComponentProps<"indexerManager">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.indexerManager"); const t = useI18n("widget.indexerManager");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds }); const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
+6 -4
View File
@@ -20,11 +20,11 @@ import { formatBytes, formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected"; import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
// Board grid: a widget N cells wide is N*200 + (N-1)*12 px (1→200, 2→412, 3→624, 4→836). // Visible width drives how much info fits side-by-side; height only matters for the
// Width drives how much info fits side-by-side; height only matters for the
// single-row (short) case where the widget must drop down to essentials. // single-row (short) case where the widget must drop down to essentials.
const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px) const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px)
const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+) const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+)
@@ -43,9 +43,11 @@ export default function LlamacppWidget({
options, options,
width, width,
height, height,
displayScale,
displayMode = "compact", displayMode = "compact",
}: WidgetComponentProps<"llamacpp">) { }: WidgetComponentProps<"llamacpp">) {
const integrationId = integrationIds[0]; const integrationId = integrationIds[0];
const layoutSize = getWidgetLayoutSize({ width, height, displayScale, displayMode });
if (!integrationId) { if (!integrationId) {
throw new NoIntegrationSelectedError(); throw new NoIntegrationSelectedError();
} }
@@ -54,8 +56,8 @@ export default function LlamacppWidget({
<LlamacppContent <LlamacppContent
integrationId={integrationId} integrationId={integrationId}
options={options} options={options}
width={width} width={layoutSize.width}
height={height} height={layoutSize.height}
displayMode={displayMode} displayMode={displayMode}
/> />
); );
@@ -26,6 +26,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -66,10 +67,13 @@ const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: b
export default function MediaMissingWidget({ export default function MediaMissingWidget({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
displayMode, displayMode,
widgetRuntimeRef, widgetRuntimeRef,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaMissing">) { }: WidgetComponentProps<"mediaMissing">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaMissing"); const t = useI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing; const showMissing = isAdvanced || options.showMissing;
@@ -13,6 +13,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from
import { openMediaRequestSearch } from "@homarr/spotlight"; import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -28,8 +29,13 @@ export default function MediaServerWidget({
integrationIds, integrationIds,
isEditMode, isEditMode,
options, options,
width, width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaRequests-requestList"); const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set( const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess() useIntegrationsWithInteractAccess()
@@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight"; import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -38,9 +39,18 @@ const JELLYSEERR_COLOR = "#6677CC";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width: logicalWidth,
height, height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) { }: WidgetComponentProps<"mediaRequests-requestStats">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.mediaRequests-requestStats"); const t = useI18n("widget.mediaRequests-requestStats");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }); const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
@@ -38,6 +38,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -169,10 +170,13 @@ function StreamTableHeader({
export default function MediaServerWidget({ export default function MediaServerWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
isEditMode, isEditMode,
displayMode, displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaServer">) { }: WidgetComponentProps<"mediaServer">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds, integrationIds,
showOnlyPlaying: options.showOnlyPlaying, showOnlyPlaying: options.showOnlyPlaying,
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -51,11 +52,19 @@ const getTranscodingWidthLayout = (width: number) =>
export default function MediaTranscodingWidget({ export default function MediaTranscodingWidget({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
widgetRuntimeRef, widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"mediaTranscoding">) { }: WidgetComponentProps<"mediaTranscoding">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced); const queuePageSize = getQueuePageSize(height, isAdvanced);
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({ const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
@@ -9,14 +9,22 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ export default function MinecraftServerStatusWidget({
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"minecraftServerStatus">) { }: WidgetComponentProps<"minecraftServerStatus">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options); const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus"); const t = useI18n("widget.minecraftServerStatus");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -5,6 +5,7 @@ import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text }
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state"; import { getUsableWidgetQueryData } from "../../common/query-state";
@@ -41,12 +42,12 @@ export default function NetworkControllerNetworkStatusWidget({
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0); const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0); const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0); const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
let responsiveWidth = width; const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
let responsiveHeight = height; width,
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) { height,
responsiveWidth *= displayScale; displayScale,
responsiveHeight *= displayScale; displayMode,
} });
const layout = getNetworkControllerStatusLayout({ const layout = getNetworkControllerStatusLayout({
width: responsiveWidth, width: responsiveWidth,
height: responsiveHeight, height: responsiveHeight,
+6 -1
View File
@@ -58,6 +58,7 @@ import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { InlineConfirmActionIcon } from "@homarr/ui"; import { InlineConfirmActionIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css"; import actionTargetClasses from "../common/action-target.module.css";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item"; import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
@@ -88,9 +89,13 @@ export function Notebook({
isEditMode, isEditMode,
boardId, boardId,
itemId, itemId,
height, height: logicalHeight,
displayMode = "compact", displayMode = "compact",
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"notebook">) { }: WidgetComponentProps<"notebook">) {
const { height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const [content, setContent] = useState(options.content); const [content, setContent] = useState(options.content);
const previousContentRef = useRef(options.content); const previousContentRef = useRef(options.content);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getCompactStatLayout } from "../common/compact-stat-layout"; import { getCompactStatLayout } from "../common/compact-stat-layout";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -149,12 +150,12 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey) .map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey))); .filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
let responsiveWidth = width; const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
let responsiveHeight = height; width,
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { height,
responsiveWidth *= displayScale; displayScale,
responsiveHeight *= displayScale; displayMode,
} });
const advanced = displayMode === "advanced"; const advanced = displayMode === "advanced";
const layout = getCompactStatLayout({ const layout = getCompactStatLayout({
width: responsiveWidth, width: responsiveWidth,
+11 -2
View File
@@ -20,6 +20,7 @@ import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -70,10 +71,18 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
export default function PatchMonWidget({ export default function PatchMonWidget({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"patchmon">) { }: WidgetComponentProps<"patchmon">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.patchmon"); const t = useI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? ""; const integrationId = integrationIds[0] ?? "";
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 }); const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
+14 -1
View File
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -43,7 +44,19 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
}; };
}; };
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) { export default function RssFeed({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"rssFeed">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const feed = useLiveFeed({ const feed = useLiveFeed({
urls: options.feedUrls, urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -10,6 +10,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout"; import { isSmartHomeTiny } from "../layout";
import { getEntityStateLabel } from "./state"; import { getEntityStateLabel } from "./state";
@@ -18,10 +19,18 @@ export default function SmartHomeEntityStateWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-entityState">) { }: WidgetComponentProps<"smartHome-entityState">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.smartHome-entityState"); const t = useI18n("widget.smartHome-entityState");
const tWidgetCommon = useI18n("widget.common"); const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -21,6 +21,7 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout"; import { isSmartHomeTiny } from "../layout";
@@ -28,10 +29,17 @@ export default function SmartHomeTriggerAutomationWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-executeAutomation">) { }: WidgetComponentProps<"smartHome-executeAutomation">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0]; const integrationId = integrationIds[0];
const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId); const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const [lastExecutedAt, setLastExecutedAt] = React.useState<Date | null>(null); const [lastExecutedAt, setLastExecutedAt] = React.useState<Date | null>(null);
@@ -14,10 +14,12 @@ export function AveragesSection({
stats, stats,
width = 500, width = 500,
compactSurface = false, compactSurface = false,
displayScale,
}: { }: {
stats: SpeedtestTrackerStats; stats: SpeedtestTrackerStats;
width?: number; width?: number;
compactSurface?: boolean; compactSurface?: boolean;
displayScale?: number;
}) { }) {
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
@@ -28,6 +30,7 @@ export function AveragesSection({
</SectionLabel> </SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}> <SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown} icon={IconArrowDown}
color="blue" color="blue"
value={formatStatsSpeed(stats.download)} value={formatStatsSpeed(stats.download)}
@@ -36,6 +39,7 @@ export function AveragesSection({
legacySurface={compactSurface} legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp} icon={IconArrowUp}
color="teal" color="teal"
value={formatStatsSpeed(stats.upload)} value={formatStatsSpeed(stats.upload)}
@@ -44,6 +48,7 @@ export function AveragesSection({
legacySurface={compactSurface} legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine} icon={IconWaveSine}
color="orange" color="orange"
value={`${stats.ping.avg.toFixed(1)} ms`} value={`${stats.ping.avg.toFixed(1)} ms`}
@@ -6,6 +6,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -20,8 +21,13 @@ const emptyDashboardData = [] as const;
export default function SpeedtestTrackerWidget({ export default function SpeedtestTrackerWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"speedtestTracker">) { }: WidgetComponentProps<"speedtestTracker">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
@@ -44,10 +50,20 @@ export default function SpeedtestTrackerWidget({
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults; const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = showLatestResult && combined.latestResult && ( const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={width} compactSurface /> <LatestResultSection
result={combined.latestResult}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
); );
const averages = showStats && combined.stats && ( const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={width} compactSurface /> <AveragesSection
stats={combined.stats}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
); );
return ( return (
@@ -14,10 +14,12 @@ export function LatestResultSection({
result, result,
width = 500, width = 500,
compactSurface = false, compactSurface = false,
displayScale,
}: { }: {
result: SpeedtestTrackerResult; result: SpeedtestTrackerResult;
width?: number; width?: number;
compactSurface?: boolean; compactSurface?: boolean;
displayScale?: number;
}) { }) {
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
@@ -40,6 +42,7 @@ export function LatestResultSection({
</Group> </Group>
<SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}> <SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown} icon={IconArrowDown}
color="blue" color="blue"
value={formatResultSpeed(result, "download")} value={formatResultSpeed(result, "download")}
@@ -47,6 +50,7 @@ export function LatestResultSection({
legacySurface={compactSurface} legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp} icon={IconArrowUp}
color="teal" color="teal"
value={formatResultSpeed(result, "upload")} value={formatResultSpeed(result, "upload")}
@@ -54,6 +58,7 @@ export function LatestResultSection({
legacySurface={compactSurface} legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine} icon={IconWaveSine}
color="orange" color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"} value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
@@ -62,6 +67,7 @@ export function LatestResultSection({
/> />
{result.healthy !== null && ( {result.healthy !== null && (
<SpeedStatCard <SpeedStatCard
displayScale={displayScale}
icon={result.healthy ? IconCircleCheck : IconCircleX} icon={result.healthy ? IconCircleCheck : IconCircleX}
color={result.healthy ? "green" : "red"} color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")} value={result.healthy ? t("healthy") : t("unhealthy")}
@@ -1,5 +1,6 @@
"use client"; "use client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core"; import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
@@ -14,6 +15,7 @@ export interface SpeedStatCardProps {
label: string; label: string;
compact?: boolean; compact?: boolean;
legacySurface?: boolean; legacySurface?: boolean;
displayScale?: number;
} }
export function SpeedStatCard({ export function SpeedStatCard({
@@ -23,8 +25,10 @@ export function SpeedStatCard({
label, label,
compact = false, compact = false,
legacySurface = false, legacySurface = false,
displayScale,
}: SpeedStatCardProps) { }: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>(); const { ref, height: logicalHeight, width: logicalWidth } = useElementSize<HTMLDivElement>();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const board = useRequiredBoard(); const board = useRequiredBoard();
const isWide = width > height + 20; const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38; const hideLabel = height > 0 && height <= 38;
+10 -2
View File
@@ -7,6 +7,7 @@ import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -71,10 +72,17 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
export default function StockPriceWidget({ export default function StockPriceWidget({
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode = "compact", displayMode = "compact",
displayScale,
}: WidgetComponentProps<"stockPrice">) { }: WidgetComponentProps<"stockPrice">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.stockPrice"); const t = useI18n("widget.stockPrice");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
@@ -12,6 +12,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false, advanced = false,
displayScale,
}: { }: {
usageOverTime: { usageOverTime: {
up: number; up: number;
@@ -20,6 +21,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
displayScale?: number;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ const chartData = usageOverTime.map((usage, index) => ({
index, index,
@@ -51,6 +53,7 @@ export const CombinedNetworkTrafficChart = ({
return ( return (
<CommonChart <CommonChart
displayScale={displayScale}
data={chartData} data={chartData}
dataKey={"index"} dataKey={"index"}
series={[ series={[
@@ -1,22 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
CSSProperties,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import { useState } from "react"; import { useState } from "react";
import type { AreaChartSeries } from "@mantine/charts"; import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts";
import { import { Card, Center, Group, Stack, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
Card,
Center,
Group,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { YAxisProps } from "recharts"; import type { YAxisProps } from "recharts";
@@ -24,6 +11,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale } from "../../common/widget-layout-size";
import type { LabelDisplayModeOption } from ".."; import type { LabelDisplayModeOption } from "..";
export const CommonChart = ({ export const CommonChart = ({
@@ -38,6 +26,7 @@ export const CommonChart = ({
lastValue, lastValue,
chartType = "line", chartType = "line",
advanced = false, advanced = false,
displayScale,
}: { }: {
data: Record<string, any>[]; data: Record<string, any>[];
dataKey: string; dataKey: string;
@@ -53,8 +42,11 @@ export const CommonChart = ({
lastValue?: string; lastValue?: string;
chartType?: "line" | "area"; chartType?: "line" | "area";
advanced?: boolean; advanced?: boolean;
displayScale?: number;
}) => { }) => {
const { ref: elementSizeRef, height } = useElementSize(); const { ref: elementSizeRef, height: logicalHeight } = useElementSize();
const height =
logicalHeight * getWidgetDisplayScale({ displayScale, displayMode: advanced ? "advanced" : "compact" });
const theme = useMantineTheme(); const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light"); const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -62,28 +54,22 @@ export const CommonChart = ({
const ref = useMergedRef(elementSizeRef, hoverRef); const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100; const opacity = board.opacity / 100;
let backgroundColor = let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
colorScheme === "dark"
? `rgba(57, 57, 57, ${opacity})`
: `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" }; const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = { const chartRootStyle: CSSProperties = {
padding: 5, padding: 5,
borderRadius: theme.radius[board.itemRadius], borderRadius: theme.radius[board.itemRadius],
}; };
if (advanced) { if (advanced) {
backgroundColor = backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border = cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; "1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor; chartRootStyle.backgroundColor = backgroundColor;
} }
const ChartComponent = chartType === "line" ? LineChart : AreaChart; const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
const showText =
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
// Track which data point the cursor is over so the tooltip can reflect that point // 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. // instead of always showing the latest value while scrubbing across the chart.
@@ -100,20 +86,12 @@ export const CommonChart = ({
// Clamp on read rather than syncing via an effect - handles data growing/shrinking // Clamp on read rather than syncing via an effect - handles data growing/shrinking
// between renders (e.g. history resetting on integration change) without ever // between renders (e.g. history resetting on integration change) without ever
// handing a stale or out-of-range index to tooltipLabel. // handing a stale or out-of-range index to tooltipLabel.
const clampedHoveredIndex = Math.max( const clampedHoveredIndex = Math.max(0, Math.min(hoveredIndex, data.length - 1));
0,
Math.min(hoveredIndex, data.length - 1),
);
const resolvedTooltipLabel = const resolvedTooltipLabel =
typeof tooltipLabel === "function" typeof tooltipLabel === "function" ? tooltipLabel(clampedHoveredIndex) : (tooltipLabel ?? lastValue);
? tooltipLabel(clampedHoveredIndex)
: (tooltipLabel ?? lastValue);
return ( return (
<Tooltip.Floating <Tooltip.Floating label={resolvedTooltipLabel} disabled={data.length <= 1 || !resolvedTooltipLabel}>
label={resolvedTooltipLabel}
disabled={data.length <= 1 || !resolvedTooltipLabel}
>
<Card <Card
ref={ref} ref={ref}
h={"100%"} h={"100%"}
@@ -149,33 +127,19 @@ export const CommonChart = ({
</Text> </Text>
)} )}
{lastValue && ( {lastValue && (
<Text <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
lineClamp={1}
>
{lastValue} {lastValue}
</Text> </Text>
)} )}
</Group> </Group>
)} )}
{advanced && {advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
data.length > 1 && <Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
height > 0 && <Text size="xs" fw={600}>
height <= 40 && {lastValue}
lastValue && </Text>
!hovered && ( </Center>
<Center )}
pos="absolute"
w="100%"
h="100%"
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{data.length <= 1 ? ( {data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%"> <Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}> <Stack px={"xs"} align={"center"} gap={4}>
@@ -187,12 +151,7 @@ export const CommonChart = ({
/> />
)} )}
{showText && ( {showText && (
<Text <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
fw={"bold"}
ta="center"
>
{title} {title}
</Text> </Text>
)} )}
@@ -10,16 +10,19 @@ export const SystemResourceCPUChart = ({
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false, advanced = false,
displayScale,
}: { }: {
cpuUsageOverTime: number[]; cpuUsageOverTime: number[];
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
displayScale?: number;
}) => { }) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
return ( return (
<CommonChart <CommonChart
displayScale={displayScale}
data={chartData} data={chartData}
dataKey={"index"} dataKey={"index"}
series={[{ name: "usage", color: "blue.5" }]} series={[{ name: "usage", color: "blue.5" }]}
@@ -10,16 +10,19 @@ export const SystemResourceGPUChart = ({
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false, advanced = false,
displayScale,
}: { }: {
gpuUsageOverTime: number[]; gpuUsageOverTime: number[];
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
displayScale?: number;
}) => { }) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
return ( return (
<CommonChart <CommonChart
displayScale={displayScale}
data={chartData} data={chartData}
dataKey={"index"} dataKey={"index"}
series={[{ name: "usage", color: "grape.5" }]} series={[{ name: "usage", color: "grape.5" }]}
@@ -12,12 +12,14 @@ export const SystemResourceMemoryChart = ({
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false, advanced = false,
displayScale,
}: { }: {
memoryUsageOverTime: number[]; memoryUsageOverTime: number[];
totalCapacityInBytes: number; totalCapacityInBytes: number;
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
displayScale?: number;
}) => { }) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ const chartData = memoryUsageOverTime.map((usage, index) => ({
index, index,
@@ -45,6 +47,7 @@ export const SystemResourceMemoryChart = ({
return ( return (
<CommonChart <CommonChart
displayScale={displayScale}
data={chartData} data={chartData}
dataKey={"index"} dataKey={"index"}
series={[{ name: "usage", color: "red.6" }]} series={[{ name: "usage", color: "red.6" }]}
@@ -12,12 +12,14 @@ export const NetworkTrafficChart = ({
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false, advanced = false,
displayScale,
}: { }: {
usageOverTime: number[]; usageOverTime: number[];
isUp: boolean; isUp: boolean;
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
displayScale?: number;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage })); const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
@@ -29,6 +31,7 @@ export const NetworkTrafficChart = ({
return ( return (
<CommonChart <CommonChart
displayScale={displayScale}
data={chartData} data={chartData}
dataKey={"index"} dataKey={"index"}
series={[{ name: "usage", color: "yellow.5" }]} series={[{ name: "usage", color: "yellow.5" }]}
@@ -5,6 +5,7 @@ import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -89,6 +90,7 @@ export default function SystemResources({
options, options,
width, width,
height, height,
displayScale,
displayMode, displayMode,
}: WidgetComponentProps<"systemResources">) { }: WidgetComponentProps<"systemResources">) {
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
@@ -150,6 +152,7 @@ export default function SystemResources({
width={availableWidth} width={availableWidth}
height={availableHeight} height={availableHeight}
isAdvanced={isAdvanced} isAdvanced={isAdvanced}
displayScale={displayScale}
showTitle={isAdvanced || data.length > 1} showTitle={isAdvanced || data.length > 1}
/> />
); );
@@ -211,6 +214,7 @@ interface SystemChartsProps {
height: number; height: number;
isAdvanced: boolean; isAdvanced: boolean;
showTitle: boolean; showTitle: boolean;
displayScale?: number;
} }
const SystemCharts = ({ const SystemCharts = ({
@@ -223,13 +227,20 @@ const SystemCharts = ({
height, height,
isAdvanced, isAdvanced,
showTitle, showTitle,
displayScale,
}: SystemChartsProps) => { }: SystemChartsProps) => {
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode: isAdvanced ? "advanced" : "compact",
});
const networkItems = getNetworkHistory(items); const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({ const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts, configuredCharts: options.visibleCharts,
hasGpu, hasGpu,
hasNetwork: networkItems.length > 0, hasNetwork: networkItems.length > 0,
height, height: responsiveHeight,
isAdvanced, isAdvanced,
}); });
const showNetwork = visibleCharts.includes("network"); const showNetwork = visibleCharts.includes("network");
@@ -270,6 +281,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced} advanced={isAdvanced}
displayScale={displayScale}
/> />
</Box> </Box>
)} )}
@@ -281,6 +293,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced} advanced={isAdvanced}
displayScale={displayScale}
/> />
</Box> </Box>
)} )}
@@ -291,11 +304,12 @@ const SystemCharts = ({
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced} advanced={isAdvanced}
displayScale={displayScale}
/> />
</Box> </Box>
)} )}
{showNetwork && {showNetwork &&
(width >= 300 ? ( (responsiveWidth >= 300 ? (
<Group h="100%" gap="xs" grow wrap="nowrap"> <Group h="100%" gap="xs" grow wrap="nowrap">
<NetworkTrafficChart <NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.down)} usageOverTime={networkItems.map((network) => network.down)}
@@ -303,6 +317,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced} advanced={isAdvanced}
displayScale={displayScale}
/> />
<NetworkTrafficChart <NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)} usageOverTime={networkItems.map((network) => network.up)}
@@ -310,6 +325,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced} advanced={isAdvanced}
displayScale={displayScale}
/> />
</Group> </Group>
) : ( ) : (
@@ -319,6 +335,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced} advanced={isAdvanced}
displayScale={displayScale}
/> />
</Box> </Box>
))} ))}
+11 -2
View File
@@ -29,6 +29,7 @@ import {
requestBrowserNotificationPermission, requestBrowserNotificationPermission,
showBrowserNotification, showBrowserNotification,
} from "../common/browser-alert"; } from "../common/browser-alert";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { useLocalWidgetState } from "../common/use-local-widget-state"; import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now"; import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -52,12 +53,20 @@ const completionSoundIntervalMs = 300;
export default function TimerWidget({ export default function TimerWidget({
boardId, boardId,
displayMode, displayMode,
height, height: logicalHeight,
isEditMode, isEditMode,
itemId, itemId,
options, options,
width, width: logicalWidth,
displayScale,
}: WidgetComponentProps<"timer">) { }: WidgetComponentProps<"timer">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const now = useWidgetNow("second"); const now = useWidgetNow("second");
const t = useI18n("widget.timer"); const t = useI18n("widget.timer");
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
+11 -2
View File
@@ -4,6 +4,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input"; import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import { formatLocalizedTime } from "../common/locale"; import { formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -11,10 +12,18 @@ import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({ export default function TimetableWidget({
options, options,
displayMode = "compact", displayMode = "compact",
width, width: logicalWidth,
height, height: logicalHeight,
itemId, itemId,
displayScale,
}: WidgetComponentProps<"timetable">) { }: WidgetComponentProps<"timetable">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.timetable"); const t = useI18n("widget.timetable");
if (!options.station) { if (!options.station) {
+6 -1
View File
@@ -6,6 +6,7 @@ import { clientApi } from "@homarr/api/client";
import type { TracearrDashboardData } from "@homarr/integrations/types"; import type { TracearrDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -36,9 +37,13 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is
export default function TracearrWidget({ export default function TracearrWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
displayMode = "compact", displayMode = "compact",
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"tracearr">) { }: WidgetComponentProps<"tracearr">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
throw new NoIntegrationDataError(); throw new NoIntegrationDataError();
} }
+5 -1
View File
@@ -16,6 +16,7 @@ import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSumma
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -103,9 +104,12 @@ export default function TraefikWidget(props: WidgetComponentProps<"traefik">) {
function TraefikWidgetContent({ function TraefikWidgetContent({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
displayMode = "compact", displayMode = "compact",
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"traefik">) { }: WidgetComponentProps<"traefik">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.traefik"); const t = useI18n("widget.traefik");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }); const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
+10 -2
View File
@@ -6,6 +6,7 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { UmamiContent } from "./umami-content"; import { UmamiContent } from "./umami-content";
@@ -13,11 +14,18 @@ import { UmamiContent } from "./umami-content";
export default function UmamiWidget({ export default function UmamiWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode = "compact", displayMode = "compact",
widgetRuntimeRef, widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"umami">) { }: WidgetComponentProps<"umami">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0] ?? ""; const integrationId = integrationIds[0] ?? "";
const commonTopInput = { const commonTopInput = {
integrationId, integrationId,
+13 -6
View File
@@ -20,6 +20,7 @@ import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
import type { ScopedTranslationFunction } from "@homarr/translation"; import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -34,20 +35,26 @@ const statusColors: Record<UpsStatus, string> = {
unknown: "gray", unknown: "gray",
}; };
const neutralSurfaceBackground = const neutralSurfaceBackground = "rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
"rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))"; const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
type UpsLayout = "mini" | "compact" | "full"; type UpsLayout = "mini" | "compact" | "full";
export default function UpsWidget({ export default function UpsWidget({
options, options,
integrationIds, integrationIds,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode = "compact", displayMode = "compact",
displayScale,
}: WidgetComponentProps<"ups">) { }: WidgetComponentProps<"ups">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError(); throw new NoIntegrationSelectedError();
} }
+10 -2
View File
@@ -9,6 +9,7 @@ import { formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -82,10 +83,17 @@ const heroVariantByRing = {
export default function UptimeKumaWidget({ export default function UptimeKumaWidget({
integrationIds, integrationIds,
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode = "compact", displayMode = "compact",
displayScale,
}: WidgetComponentProps<"uptimeKuma">) { }: WidgetComponentProps<"uptimeKuma">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
throw new NoIntegrationDataError(); throw new NoIntegrationDataError();
} }
+15 -1
View File
@@ -5,11 +5,25 @@ import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
import { VpnIntegrationCard } from "./vpn-card"; import { VpnIntegrationCard } from "./vpn-card";
export default function VpnWidget({ options, integrationIds, width, height }: WidgetComponentProps<"vpn">) { export default function VpnWidget({
options,
integrationIds,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"vpn">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summariesQuery = clientApi.widget.vpn.getSummaries.useQuery({ const summariesQuery = clientApi.widget.vpn.getSummaries.useQuery({
...options, ...options,
integrationIds, integrationIds,
+10 -2
View File
@@ -5,6 +5,7 @@ import { Box, Center, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -15,10 +16,17 @@ import { CompactWeather } from "./compact";
export default function WeatherWidget({ export default function WeatherWidget({
isEditMode, isEditMode,
options, options,
width, width: logicalWidth,
height, height: logicalHeight,
displayMode, displayMode,
displayScale,
}: WidgetComponentProps<"weather">) { }: WidgetComponentProps<"weather">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.weather"); const t = useI18n("widget.weather");
const input = { const input = {
latitude: options.location.latitude, latitude: options.location.latitude,
+11 -8
View File
@@ -11,12 +11,21 @@ import type { WudContainerUpdate } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css"; import classes from "./component.module.css";
export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) { export default function WudWidget({
integrationIds,
options,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"wud">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const integrationId = integrationIds[0]; const integrationId = integrationIds[0];
if (!integrationId) return null; if (!integrationId) return null;
return ( return (
@@ -208,13 +217,7 @@ const UpdateCard = ({
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText; const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
return ( return (
<Card <Card className={combineClasses(className)} radius={radius} p="xs" bg="transparent" style={{ overflow: "visible" }}>
className={combineClasses(className)}
radius={radius}
p="xs"
bg="transparent"
style={{ overflow: "visible" }}
>
<Group justify="space-between" wrap="nowrap" gap="xs" miw={0}> <Group justify="space-between" wrap="nowrap" gap="xs" miw={0}>
<Text size="xs" fw={500} lineClamp={1} miw={0}> <Text size="xs" fw={500} lineClamp={1} miw={0}>
{update.name} {update.name}