feat: refine widget interactions
This commit is contained in:
1 parent
a8a7545f40
commit
842e86012d
107 files changed
+3144
-1233
No files matched your search
@@ -38,7 +38,7 @@ import editOn from "./img/edit.png";
|
||||
|
||||
### Editing
|
||||
|
||||
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
|
||||
When your permissions allow it, hover the widget, focus within it, or use a touch device to reveal the edit button in the top right. You can also double-click the document to edit it directly.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IconArrowsMaximize, IconMouse2, IconPlus } from "@tabler/icons-react";
|
||||
import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
const interactions = [
|
||||
{
|
||||
icon: IconArrowsMaximize,
|
||||
title: "Advanced view",
|
||||
description:
|
||||
"Hold Shift while hovering (then pin it), press Shift + Enter, or choose Open advanced view from the widget menu.",
|
||||
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
|
||||
},
|
||||
{
|
||||
icon: IconMouse2,
|
||||
@@ -19,6 +19,12 @@ const interactions = [
|
||||
description:
|
||||
"With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutGrid,
|
||||
title: "Adaptive detail",
|
||||
description:
|
||||
"Resize a widget to tune its density. Compact views keep primary status visible, then reveal details and controls as space or intent allows.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const WidgetInteractionGuide = () => (
|
||||
@@ -30,7 +36,7 @@ export const WidgetInteractionGuide = () => (
|
||||
Hide shortcuts
|
||||
</span>
|
||||
</summary>
|
||||
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-3 dark:border-[#333]">
|
||||
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 xl:grid-cols-4 dark:border-[#333]">
|
||||
{interactions.map(({ icon: Icon, title, description }) => (
|
||||
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
|
||||
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
|
||||
@@ -40,8 +46,8 @@ export const WidgetInteractionGuide = () => (
|
||||
))}
|
||||
</div>
|
||||
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
|
||||
Advanced view reuses the widget's live data and cache. Touch devices expose a maximize control; board actions
|
||||
respect your permissions and right-click preference.
|
||||
Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. Touch
|
||||
devices expose a maximize control; app tiles stay compact.
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
@@ -1,28 +1,18 @@
|
||||
.focusScope {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 198;
|
||||
background: rgba(0, 0, 0, 0.64);
|
||||
backdrop-filter: blur(6px) saturate(0.75);
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.surface {
|
||||
position: fixed;
|
||||
z-index: 199;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
overscroll-behavior: contain;
|
||||
animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: top left;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.backdropClosing {
|
||||
@@ -45,7 +35,7 @@
|
||||
.surfaceHostAdvanced {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 199;
|
||||
z-index: calc(var(--mantine-z-index-modal) - 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
@@ -58,33 +48,8 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surfaceControlsAdvanced {
|
||||
right: 0;
|
||||
bottom: auto;
|
||||
height: 60px;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.surfaceTitle {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 120px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pinButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 60px;
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -141,8 +106,7 @@
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.backdrop {
|
||||
backdrop-filter: none;
|
||||
background: Canvas;
|
||||
opacity: 0.8;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Portal, RemoveScroll } from "@mantine/core";
|
||||
import { Overlay, Portal } from "@mantine/core";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
@@ -14,17 +14,21 @@ interface ActiveFocus {
|
||||
itemId: string;
|
||||
source: HTMLElement;
|
||||
sourceRect: FocusRect;
|
||||
pinned: boolean;
|
||||
activation: "preview" | "manual";
|
||||
autofocusClose: boolean;
|
||||
phase: "visible" | "closing";
|
||||
}
|
||||
|
||||
interface OpenAdvancedFocusOptions {
|
||||
activation?: ActiveFocus["activation"];
|
||||
autofocusClose?: boolean;
|
||||
}
|
||||
|
||||
interface AdvancedFocusContextValue {
|
||||
active: ActiveFocus | null;
|
||||
open: (itemId: string, source: HTMLElement, pinned?: boolean) => void;
|
||||
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
|
||||
close: (restoreFocus?: boolean) => void;
|
||||
dismiss: (itemId: string) => void;
|
||||
pin: (autofocusClose?: boolean) => void;
|
||||
hover: (itemId: string, source: HTMLElement) => void;
|
||||
leave: (itemId: string) => void;
|
||||
}
|
||||
@@ -64,7 +68,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(itemId: string, source: HTMLElement, pinned = false) => {
|
||||
(itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => {
|
||||
if (isEditMode) return;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
@@ -73,8 +77,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
itemId,
|
||||
source,
|
||||
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
pinned,
|
||||
autofocusClose: pinned,
|
||||
activation: options.activation ?? "manual",
|
||||
autofocusClose: options.autofocusClose ?? false,
|
||||
phase: "visible",
|
||||
});
|
||||
},
|
||||
@@ -101,15 +105,23 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
cancelHoverTimer();
|
||||
const hovered = hoveredRef.current;
|
||||
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
|
||||
hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS);
|
||||
hoverTimerRef.current = setTimeout(
|
||||
() => open(hovered.itemId, hovered.source, { activation: "preview" }),
|
||||
HOVER_DELAY_MS,
|
||||
);
|
||||
}, [cancelHoverTimer, open]);
|
||||
|
||||
const hover = useCallback(
|
||||
(itemId: string, source: HTMLElement) => {
|
||||
hoveredRef.current = { itemId, source };
|
||||
const current = activeRef.current;
|
||||
if (current?.activation === "preview" && current.itemId !== itemId) {
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
}
|
||||
startHoverTimer();
|
||||
},
|
||||
[startHoverTimer],
|
||||
[cancelCloseTimer, startHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
const leave = useCallback(
|
||||
@@ -130,15 +142,6 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
[cancelCloseTimer, cancelHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
const pin = useCallback(
|
||||
(autofocusClose = false) => {
|
||||
const current = activeRef.current;
|
||||
if (!current || current.pinned || current.phase === "closing") return;
|
||||
updateActive({ ...current, pinned: true, autofocusClose });
|
||||
},
|
||||
[updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && activeRef.current) {
|
||||
@@ -151,7 +154,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
if (isEditMode) return;
|
||||
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
|
||||
if (activeRef.current?.pinned) return;
|
||||
if (activeRef.current?.activation === "manual") return;
|
||||
shiftHeldRef.current = true;
|
||||
startHoverTimer();
|
||||
};
|
||||
@@ -159,12 +162,12 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
if (event.key !== "Shift") return;
|
||||
shiftHeldRef.current = false;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current && !activeRef.current.pinned) close(false);
|
||||
if (activeRef.current?.activation === "preview") close(false);
|
||||
};
|
||||
const handleBlur = () => {
|
||||
shiftHeldRef.current = false;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current && !activeRef.current.pinned) close(false);
|
||||
if (activeRef.current?.activation === "preview") close(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
@@ -184,20 +187,22 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [close, isEditMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ active, open, close, dismiss, pin, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open, pin],
|
||||
() => ({ active, open, close, dismiss, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdvancedFocusContext.Provider value={value}>
|
||||
<RemoveScroll enabled={active !== null} className={classes.focusScope}>
|
||||
{children}
|
||||
</RemoveScroll>
|
||||
{children}
|
||||
{active && (
|
||||
<Portal>
|
||||
<Box
|
||||
<Overlay
|
||||
fixed
|
||||
data-advanced-focus-overlay
|
||||
backgroundOpacity={0.38}
|
||||
blur={0}
|
||||
zIndex="calc(var(--mantine-z-index-modal) - 2)"
|
||||
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
|
||||
onPointerDown={() => close()}
|
||||
aria-hidden
|
||||
/>
|
||||
</Portal>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isEscapeOwnedByNestedOverlay } from "./escape";
|
||||
describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
test("allows Escape from the advanced surface itself", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
surface.setAttribute("role", "region");
|
||||
const button = document.createElement("button");
|
||||
surface.append(button);
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
|
||||
test("leaves Escape to a nested Mantine modal", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
surface.setAttribute("role", "region");
|
||||
const modal = document.createElement("section");
|
||||
modal.setAttribute("role", "dialog");
|
||||
const input = document.createElement("input");
|
||||
@@ -25,7 +25,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
|
||||
test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
surface.setAttribute("role", "region");
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("role", role);
|
||||
const target = document.createElement("button");
|
||||
@@ -36,7 +36,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
|
||||
test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
surface.setAttribute("role", "region");
|
||||
const target = document.createElement("button");
|
||||
target.setAttribute("data-mantine-stop-propagation", "true");
|
||||
surface.append(target);
|
||||
@@ -46,7 +46,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
|
||||
test("does not mistake the compact widget source for a nested overlay", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
surface.setAttribute("role", "region");
|
||||
const source = document.createElement("button");
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdvancedFocusRect } from "./geometry";
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "./geometry";
|
||||
|
||||
describe("getAdvancedFocusRect", () => {
|
||||
it("expands a widget and keeps it inside the viewport", () => {
|
||||
const rect = getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 });
|
||||
it("expands around the widget instead of centering a modal", () => {
|
||||
const rect = getAdvancedFocusRect({ left: 450, top: 300, width: 300, height: 200 }, { width: 1200, height: 800 });
|
||||
|
||||
expect(rect.left).toBe(120);
|
||||
expect(rect.top).toBeCloseTo(118.6);
|
||||
expect(rect.width).toBe(960);
|
||||
expect(rect.height).toBeCloseTo(622.8);
|
||||
expect(rect).toEqual({ left: 200, top: 120, width: 800, height: 560 });
|
||||
expect(rect.left + rect.width / 2).toBe(600);
|
||||
expect(rect.top + rect.height / 2).toBe(400);
|
||||
});
|
||||
|
||||
it("uses the available viewport on small screens", () => {
|
||||
@@ -22,9 +21,34 @@ describe("getAdvancedFocusRect", () => {
|
||||
});
|
||||
|
||||
it("keeps controls below the fixed board header on short screens", () => {
|
||||
const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 });
|
||||
const rect = getAdvancedFocusRect({ left: 1200, top: 700, width: 500, height: 500 }, { width: 1366, height: 768 });
|
||||
|
||||
expect(rect.top).toBeGreaterThanOrEqual(84);
|
||||
expect(rect.top + rect.height).toBeLessThanOrEqual(744);
|
||||
expect(rect.left).toBeGreaterThanOrEqual(16);
|
||||
expect(rect.left + rect.width).toBeLessThanOrEqual(1350);
|
||||
expect(rect.top).toBeGreaterThanOrEqual(76);
|
||||
expect(rect.top + rect.height).toBeLessThanOrEqual(752);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdvancedFocusClosePosition", () => {
|
||||
it("keeps the close control outside the surface when the right side has room", () => {
|
||||
expect(getAdvancedFocusClosePosition({ left: 16, top: 76, width: 800, height: 560 }, 1280)).toEqual({
|
||||
left: 824,
|
||||
top: 76,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the left side when the surface is anchored to the right edge", () => {
|
||||
expect(getAdvancedFocusClosePosition({ left: 464, top: 76, width: 800, height: 560 }, 1280)).toEqual({
|
||||
left: 412,
|
||||
top: 76,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the inside corner on narrow screens", () => {
|
||||
expect(getAdvancedFocusClosePosition({ left: 8, top: 68, width: 374, height: 624 }, 390)).toEqual({
|
||||
left: 330,
|
||||
top: 76,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,22 +7,48 @@ export interface FocusRect {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FocusClosePosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const closeButtonSize = 44;
|
||||
const closeButtonGap = 8;
|
||||
|
||||
export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
|
||||
const margin = viewport.width < 640 ? 8 : 24;
|
||||
const margin = viewport.width < 640 ? 8 : 16;
|
||||
const availableWidth = Math.max(0, viewport.width - margin * 2);
|
||||
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
|
||||
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(960, availableWidth * 0.9);
|
||||
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(720, availableHeight * 0.9);
|
||||
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(800, availableWidth);
|
||||
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(560, availableHeight);
|
||||
const width = Math.min(availableWidth, Math.max(source.width, preferredWidth));
|
||||
const height = Math.min(availableHeight, Math.max(source.height, preferredHeight));
|
||||
const minimumTop = appShellHeaderHeight + margin;
|
||||
const maximumLeft = Math.max(margin, viewport.width - margin - width);
|
||||
const maximumTop = Math.max(minimumTop, viewport.height - margin - height);
|
||||
|
||||
return {
|
||||
left: Math.max(margin, (viewport.width - width) / 2),
|
||||
top: Math.max(
|
||||
appShellHeaderHeight + margin,
|
||||
appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2,
|
||||
),
|
||||
left: Math.min(maximumLeft, Math.max(margin, source.left - (width - source.width) / 2)),
|
||||
top: Math.min(maximumTop, Math.max(minimumTop, source.top - (height - source.height) / 2)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAdvancedFocusClosePosition = (surface: FocusRect, viewportWidth: number): FocusClosePosition => {
|
||||
const outsideOffset = closeButtonSize + closeButtonGap;
|
||||
const surfaceRight = surface.left + surface.width;
|
||||
|
||||
if (viewportWidth - surfaceRight >= outsideOffset) {
|
||||
return { left: surfaceRight + closeButtonGap, top: surface.top };
|
||||
}
|
||||
|
||||
if (surface.left >= outsideOffset) {
|
||||
return { left: surface.left - outsideOffset, top: surface.top };
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.max(0, Math.min(viewportWidth - closeButtonSize, surfaceRight - outsideOffset)),
|
||||
top: surface.top + closeButtonGap,
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, test } from "vitest";
|
||||
|
||||
import { hashPasswordAsync } from "../packages/auth/security";
|
||||
import {
|
||||
apps,
|
||||
boardUserPermissions,
|
||||
boards,
|
||||
itemLayouts,
|
||||
@@ -24,6 +25,7 @@ const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
|
||||
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
|
||||
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
|
||||
const boardName = "advanced-interactions-e2e";
|
||||
const emptyCellCenterXRatio = 7.5 / 12;
|
||||
|
||||
describe("Board advanced interactions", () => {
|
||||
let browser: Browser;
|
||||
@@ -67,7 +69,10 @@ describe("Board advanced interactions", () => {
|
||||
try {
|
||||
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
|
||||
const compactSurface = widget.locator(".clock-wrapper");
|
||||
const advancedDialog = page.getByRole("dialog", { name: "Date and time advanced view" });
|
||||
const advancedSurface = page.getByRole("region", { name: "Date and time advanced view" });
|
||||
const iframeAdvancedSurface = page.getByRole("region", { name: "iFrame advanced view" });
|
||||
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
|
||||
const otherWidget = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
|
||||
|
||||
await compactSurface.evaluate((element) => {
|
||||
element.setAttribute("data-lifecycle-probe", "same-instance");
|
||||
@@ -75,43 +80,60 @@ describe("Board advanced interactions", () => {
|
||||
await widget.focus();
|
||||
await page.keyboard.down("Shift");
|
||||
await widget.hover();
|
||||
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
|
||||
await expect(advancedDialog).toHaveCSS("animation-name", "none");
|
||||
const advancedBounds = await advancedDialog.boundingBox();
|
||||
expect(advancedBounds?.width).toBeGreaterThanOrEqual(900);
|
||||
await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(advancedSurface).toBeVisible({ timeout: 2_000 });
|
||||
await expect(advancedSurface).toHaveCSS("animation-name", "none");
|
||||
const compactBounds = await widget.boundingBox();
|
||||
const advancedBounds = await advancedSurface.boundingBox();
|
||||
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
|
||||
await expect(advancedSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toBeVisible();
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
|
||||
await expect(otherWidget).toBeVisible();
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await advancedSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await portalledMenuItem.hover();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
|
||||
await otherWidget.hover();
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await expect(iframeAdvancedSurface).toBeVisible({ timeout: 2_000 });
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(advancedDialog).toBeHidden();
|
||||
await expect(iframeAdvancedSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await page.keyboard.down("Shift");
|
||||
await widget.hover();
|
||||
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
|
||||
await page.getByRole("button", { name: "Keep advanced view open" }).click();
|
||||
const pinnedButton = page.getByRole("button", { name: "Advanced view is pinned" });
|
||||
await expect(pinnedButton).toBeFocused();
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(advancedDialog).toBeVisible();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(advancedDialog).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(advancedDialog).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(advancedDialog).toBeVisible();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(advancedDialog).toBeHidden();
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(advancedDialog).toBeVisible();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(advancedDialog).toBeHidden();
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
|
||||
const appWidget = page.locator(".grid-stack-item[data-kind='app'] > .grid-stack-item-content").first();
|
||||
await expect(appWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
|
||||
await appWidget.click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await appWidget.hover();
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await page.keyboard.up("Shift");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -131,7 +153,7 @@ describe("Board advanced interactions", () => {
|
||||
|
||||
await iframeSlot.focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeVisible();
|
||||
await expect(frame.locator("#state")).toHaveValue("changed");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(frame.locator("#state")).toHaveValue("changed");
|
||||
@@ -139,7 +161,7 @@ describe("Board advanced interactions", () => {
|
||||
await iframeSlot.focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeHidden();
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeHidden();
|
||||
await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
@@ -163,13 +185,18 @@ describe("Board advanced interactions", () => {
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
|
||||
const dialogBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
|
||||
const surfaceBounds = await page.getByRole("region", { name: "Date and time advanced view" }).boundingBox();
|
||||
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
|
||||
expect(dialogBounds).not.toBeNull();
|
||||
expect(surfaceBounds).not.toBeNull();
|
||||
expect(contentBounds).not.toBeNull();
|
||||
if (dialogBounds && contentBounds) expect(contentBounds.y).toBeGreaterThanOrEqual(dialogBounds.y + 60);
|
||||
if (surfaceBounds && contentBounds) {
|
||||
expect(contentBounds.y - surfaceBounds.y).toBeLessThan(32);
|
||||
expect(bounds.x >= surfaceBounds.x + surfaceBounds.width || bounds.x + bounds.width <= surfaceBounds.x).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
await closeButton.click();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
|
||||
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeHidden();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -232,7 +259,7 @@ describe("Board advanced interactions", () => {
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
|
||||
@@ -273,7 +300,7 @@ const rightClickEmptyGridSpaceAsync = async (grid: Locator) => {
|
||||
|
||||
await grid.click({
|
||||
button: "right",
|
||||
position: { x: bounds.width - 16, y: Math.min(24, bounds.height / 2) },
|
||||
position: { x: bounds.width * emptyCellCenterXRatio, y: Math.min(24, bounds.height / 2) },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -282,7 +309,7 @@ const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
|
||||
expect(bounds).not.toBeNull();
|
||||
if (!bounds) return;
|
||||
|
||||
const clientX = bounds.x + bounds.width - 16;
|
||||
const clientX = bounds.x + bounds.width * emptyCellCenterXRatio;
|
||||
const clientY = bounds.y + Math.min(24, bounds.height / 2);
|
||||
await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
@@ -296,8 +323,16 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
const sectionId = createId();
|
||||
const clockItemId = createId();
|
||||
const iframeItemId = createId();
|
||||
const appItemId = createId();
|
||||
const appId = createId();
|
||||
|
||||
await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: "Example app",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://example.com",
|
||||
});
|
||||
await db.insert(layouts).values([
|
||||
{ id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
|
||||
{ id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
|
||||
@@ -318,6 +353,13 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: appItemId,
|
||||
kind: "app",
|
||||
boardId,
|
||||
options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
]);
|
||||
await db.insert(itemLayouts).values(
|
||||
[baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
|
||||
@@ -334,11 +376,20 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
itemId: iframeItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 4,
|
||||
xOffset: 8,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: appItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 4,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 3,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await db
|
||||
|
||||
@@ -14,7 +14,8 @@ vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
|
||||
const mockCloseDispatcher = vi.fn();
|
||||
const mockDispatcher = { close: mockCloseDispatcher };
|
||||
const mockDestroyDispatcher = vi.fn().mockResolvedValue(undefined);
|
||||
const mockDispatcher = { close: mockCloseDispatcher, destroy: mockDestroyDispatcher };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -156,6 +157,41 @@ describe("timetable response bounds", () => {
|
||||
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("destroys the pinned station dispatcher when response handling fails", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("not json"));
|
||||
|
||||
await expect(
|
||||
timetableSearchStationsRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
query: "invalid-stations",
|
||||
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
|
||||
})
|
||||
.getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys the pinned timetable dispatcher when response handling fails", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("not json"));
|
||||
|
||||
await expect(
|
||||
timetableGetTimetableRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
stationId: "invalid-departures",
|
||||
limit: 1,
|
||||
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
|
||||
})
|
||||
.getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never caches more departures than requested", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
|
||||
@@ -124,9 +124,10 @@ const searchStationsAsync = async (
|
||||
query: string,
|
||||
pinnedAddresses?: TimetableResolvedAddress[],
|
||||
): Promise<Station[]> => {
|
||||
const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
|
||||
try {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
{ ...timetableFetchOptions, dispatcher },
|
||||
);
|
||||
@@ -138,6 +139,11 @@ const searchStationsAsync = async (
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
} catch (error) {
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
dispatcher = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
@@ -161,9 +167,10 @@ const getTimetableAsync = async (
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
|
||||
try {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
|
||||
stop: options.stationId,
|
||||
limit: options.limit,
|
||||
@@ -205,6 +212,11 @@ const getTimetableAsync = async (
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
dispatcher = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,34 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.homarr-anchor-actions {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.homarr-anchor-note:hover .homarr-anchor-actions,
|
||||
.homarr-anchor-note:focus-within .homarr-anchor-actions,
|
||||
.homarr-anchor-actions[data-visible="true"] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.homarr-anchor-actions {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.homarr-anchor-actions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-toolbar.ql-snow,
|
||||
.homarr-anchor-quill .ql-container.ql-snow {
|
||||
border: 0;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { Quill } from "react-quill-new";
|
||||
import type { DeltaStatic } from "react-quill-new";
|
||||
import type ReactQuillComponent from "react-quill-new";
|
||||
@@ -295,7 +296,7 @@ const AnchorNoteWidgetContent = ({
|
||||
if (!note) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
|
||||
<Stack className="homarr-anchor-note" h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
{isEditing ? (
|
||||
@@ -335,22 +336,30 @@ const AnchorNoteWidgetContent = ({
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
|
||||
{t("save")}
|
||||
{(isEditing || canEdit) && (
|
||||
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : isAdvanced ? (
|
||||
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={handleEdit} disabled={!canEdit || isUpdating}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label={t("edit")}>
|
||||
<ActionIcon aria-label={t("edit")} size="md" variant="light" onClick={handleEdit} disabled={isUpdating}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{isEditing || isAdvanced ? (
|
||||
<div
|
||||
|
||||
@@ -19,7 +19,7 @@ import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
import { PingIndicator } from "./ping/ping-indicator";
|
||||
|
||||
export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
|
||||
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
@@ -51,7 +51,6 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
const isTiny = height < 100 || width < 100;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isColumnLayout = options.layout.startsWith("column");
|
||||
|
||||
return (
|
||||
@@ -69,7 +68,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode || isAdvanced}
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Flex
|
||||
@@ -84,7 +83,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{(options.showTitle || isAdvanced) && (
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
@@ -94,13 +93,13 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{(options.descriptionDisplayMode === "normal" || isAdvanced) && (
|
||||
{options.descriptionDisplayMode === "normal" && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={isAdvanced ? 10 : Math.max(1, Math.floor((height - 48) / 18))}
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -110,11 +109,6 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && app.href && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{getDisplayHost(app.href)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
@@ -160,11 +154,3 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
const getDisplayHost = (href: string) => {
|
||||
try {
|
||||
return new URL(href).host;
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
};
|
||||
@@ -53,9 +53,16 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
const status = data.status;
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||
const statusKey = getStatusKey(status.status);
|
||||
const layout = getArchiveCompactLayout(width, height, isAdvanced);
|
||||
const countMetrics = [
|
||||
{ label: t("metric.running"), value: status.counts.running },
|
||||
...(layout.showSecondaryCounts ? [{ label: t("metric.completed"), value: status.counts.completed }] : []),
|
||||
{ label: t("metric.failed"), value: status.counts.failed },
|
||||
...(layout.showSecondaryCounts ? [{ label: t("metric.canceled"), value: status.counts.canceled }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%" style={{ overflow: "hidden" }}>
|
||||
<Stack p="xs" gap={layout.gap} h="100%" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
|
||||
@@ -69,7 +76,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && (
|
||||
{options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" lineClamp={3}>
|
||||
{status.broadcastMessage}
|
||||
@@ -77,17 +84,16 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={width < 180 || height < 120 ? 4 : 2} spacing="xs">
|
||||
<Metric label={t("metric.running")} value={status.counts.running} />
|
||||
<Metric label={t("metric.completed")} value={status.counts.completed} />
|
||||
<Metric label={t("metric.failed")} value={status.counts.failed} />
|
||||
<Metric label={t("metric.canceled")} value={status.counts.canceled} />
|
||||
<SimpleGrid cols={layout.metricColumns} spacing={layout.gap}>
|
||||
{countMetrics.map((metric) => (
|
||||
<Metric key={metric.label} {...metric} dense={layout.dense} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{status.bandwidth && (
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} />
|
||||
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} />
|
||||
{status.bandwidth && layout.showBandwidth && (
|
||||
<SimpleGrid cols={2} spacing={layout.gap}>
|
||||
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} dense />
|
||||
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} dense />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
@@ -130,17 +136,32 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
const Metric = ({ label, value, dense = false }: { label: string; value: number | string; dense?: boolean }) => (
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="md" fw={700} lineClamp={1}>
|
||||
<Text size={dense ? "sm" : "md"} fw={700} lineClamp={1} title={String(value)}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<Text size="xs" c="dimmed" lineClamp={1} title={label}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const getArchiveCompactLayout = (width: number, height: number, isAdvanced = false) => {
|
||||
const dense = !isAdvanced && (width < 240 || height < 160);
|
||||
const showSecondaryCounts = isAdvanced || (width >= 200 && height >= 120);
|
||||
const metricCount = showSecondaryCounts ? 4 : 2;
|
||||
|
||||
return {
|
||||
dense,
|
||||
gap: dense ? 4 : "xs",
|
||||
metricColumns: height < 160 ? metricCount : Math.min(metricCount, width >= 260 ? 4 : 2),
|
||||
showBandwidth: isAdvanced || height >= 190,
|
||||
showBroadcast: isAdvanced || height >= 240,
|
||||
showSecondaryCounts,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||
|
||||
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getArchiveCompactLayout } from "./component";
|
||||
|
||||
describe("archive team warrior compact layout", () => {
|
||||
test("keeps only primary counters in a minimum-size widget", () => {
|
||||
expect(getArchiveCompactLayout(180, 100)).toMatchObject({
|
||||
metricColumns: 2,
|
||||
showBandwidth: false,
|
||||
showBroadcast: false,
|
||||
showSecondaryCounts: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("adds secondary information as height becomes available", () => {
|
||||
expect(getArchiveCompactLayout(320, 260)).toMatchObject({
|
||||
metricColumns: 4,
|
||||
showBandwidth: true,
|
||||
showBroadcast: true,
|
||||
showSecondaryCounts: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
|
||||
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
import {
|
||||
AUDIO_STATS_TRANSLATION_SCOPE,
|
||||
getGridCols,
|
||||
getIconSize,
|
||||
prioritizeVisibleStats,
|
||||
getVisibleStatLimit,
|
||||
getVisibleStats,
|
||||
} from "./shared";
|
||||
|
||||
const rootClassByCompact = {
|
||||
true: classes.rootCompact,
|
||||
@@ -30,14 +37,17 @@ interface AudioStatsContentProps {
|
||||
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
|
||||
options: AudioStatsDisplayOptions;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) {
|
||||
export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) {
|
||||
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
|
||||
const visibleStats = getVisibleStats(backend, options, stats);
|
||||
const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact;
|
||||
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
|
||||
const iconSize = getIconSize(width, options.compactMode ?? false);
|
||||
const compact = options.compactMode ?? false;
|
||||
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact);
|
||||
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
|
||||
const compactKey = String(compact) as keyof typeof rootClassByCompact;
|
||||
const gridCols = getGridCols(width, visibleStats.length, compact);
|
||||
const iconSize = getIconSize(width, compact);
|
||||
|
||||
return (
|
||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
|
||||
|
||||
describe("getGridCols", () => {
|
||||
test("caps columns to visible stat count", () => {
|
||||
@@ -37,6 +37,17 @@ describe("getIconSize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStatLimit", () => {
|
||||
test("keeps compact metrics legible when height is constrained", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
|
||||
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
|
||||
});
|
||||
|
||||
test("preserves every configured metric outside compact mode", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStats", () => {
|
||||
const navidromeStats: NavidromeDashboardData = {
|
||||
artistCount: 100,
|
||||
@@ -91,3 +102,21 @@ describe("getVisibleStats", () => {
|
||||
expect(typeof result[0]?.value).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeVisibleStats", () => {
|
||||
test("puts an active session first only on compact surfaces", () => {
|
||||
const stats = getVisibleStats(
|
||||
"audiobookshelf",
|
||||
{ showLibraryCount: true, showActiveSessions: true },
|
||||
{
|
||||
libraryCount: 3,
|
||||
totalAudiobooks: 150,
|
||||
totalPodcasts: 20,
|
||||
totalListeningTimeSeconds: 86400,
|
||||
activeSessions: 2,
|
||||
},
|
||||
);
|
||||
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
|
||||
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ export default function AudioStatsWidget({
|
||||
stats={response.data}
|
||||
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
if (displayMode === "compact") return summary;
|
||||
|
||||
@@ -155,6 +155,23 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
|
||||
return Math.min(maxCols, Math.max(visibleCount, 1));
|
||||
};
|
||||
|
||||
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
|
||||
if (!compact) return visibleCount;
|
||||
|
||||
const columns = getGridCols(width, visibleCount, true);
|
||||
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
|
||||
return Math.min(visibleCount, columns * rows);
|
||||
};
|
||||
|
||||
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
|
||||
if (!compact) return stats;
|
||||
return stats.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
|
||||
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const fallbackIconSize = { true: 14, false: 16 } as const;
|
||||
|
||||
export const getIconSize = (width: number, compact: boolean): number => {
|
||||
|
||||
@@ -89,7 +89,9 @@ export default function BazarrWidget({
|
||||
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
<span className={classes.statLabel} title={t(statKey)}>
|
||||
{t(statKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
@@ -71,6 +84,7 @@ export default function BeszelAlertsWidget({
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
|
||||
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
|
||||
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
@@ -104,7 +118,7 @@ export default function BeszelAlertsWidget({
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
<Text size="xs" fw={600} c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -116,6 +130,7 @@ export default function BeszelAlertsWidget({
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
showDescription={showAlertDescriptions}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -127,7 +142,7 @@ export default function BeszelAlertsWidget({
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -139,6 +154,7 @@ export default function BeszelAlertsWidget({
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
showDescription={showAlertDescriptions}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -150,7 +166,7 @@ export default function BeszelAlertsWidget({
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -203,6 +219,7 @@ interface AlertRowProps {
|
||||
min: number;
|
||||
systemName: string;
|
||||
triggered: boolean;
|
||||
showDescription: boolean;
|
||||
}
|
||||
|
||||
const unitSuffixMap: Record<string, string> = {
|
||||
@@ -226,38 +243,43 @@ function formatAlertDescription(name: string, value: number, min: number): strin
|
||||
return `exceeds ${value}${suffix} over ${min} min`;
|
||||
}
|
||||
|
||||
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
|
||||
function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) {
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
const description = formatAlertDescription(name, value, min);
|
||||
return (
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={4}
|
||||
pr={8}
|
||||
pl={12}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{formatAlertDescription(name, value, min)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={description} disabled={showDescription} withArrow>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={showDescription ? 4 : 2}
|
||||
px={8}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
{showDescription && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
.clickableCard {
|
||||
transition: background-color 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickableCard:hover {
|
||||
.clickableCard:hover,
|
||||
.clickableCard:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.clickableCard:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clickableCard {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,9 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
|
||||
return (
|
||||
<Card
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={!onClick}
|
||||
padding={size.cardPadding}
|
||||
radius={itemRadius}
|
||||
bg="transparent"
|
||||
@@ -347,7 +350,10 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
border: "0.0625rem solid var(--border-color)",
|
||||
cursor: onClick ? "pointer" : undefined,
|
||||
color: "inherit",
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" mb={2}>
|
||||
|
||||
@@ -8,7 +8,21 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.beszelStatsContainer:hover .beszelStatsSystemToggle {
|
||||
.beszelStatsContainer:hover .beszelStatsSystemToggle,
|
||||
.beszelStatsContainer:focus-within .beszelStatsSystemToggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.beszelStatsSystemToggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.beszelStatsSystemToggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
wrapper: {
|
||||
@@ -54,6 +53,8 @@ interface BeszelTooltipProps {
|
||||
}
|
||||
|
||||
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("common");
|
||||
const isActive = active && (payload?.length ?? 0) > 0;
|
||||
if (!isActive) return null;
|
||||
|
||||
@@ -66,7 +67,9 @@ const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
if (!sorted.length) return null;
|
||||
|
||||
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
|
||||
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
|
||||
const tooltipLabel = rawTime
|
||||
? formatLocalizedDate(rawTime, locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||
: label;
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
return (
|
||||
@@ -81,7 +84,7 @@ const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.name}>{t("total")}</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -100,6 +103,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
|
||||
showTotal={showTotal}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
|
||||
wrapperStyle: { pointerEvents: "none" as const },
|
||||
isAnimationActive: false,
|
||||
});
|
||||
@@ -2,6 +2,12 @@
|
||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="light"] .card-grid {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
@@ -13,3 +19,7 @@
|
||||
.card:hover > div > div.bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible .bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -12,7 +24,45 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
|
||||
export interface CompactBookmarkLayout {
|
||||
columns: number;
|
||||
hideHostname: boolean;
|
||||
hideTitle: boolean;
|
||||
minimumItemSize: number;
|
||||
}
|
||||
|
||||
export function getCompactBookmarkLayout(
|
||||
width: number,
|
||||
height: number,
|
||||
itemCount: number,
|
||||
layout: BookmarkLayout,
|
||||
): CompactBookmarkLayout {
|
||||
const count = Math.max(1, itemCount);
|
||||
const isGrid = layout === "grid" || layout === "gridHorizontal";
|
||||
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
|
||||
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
const cellWidth = width / columns;
|
||||
const cellHeight = height / rows;
|
||||
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
|
||||
|
||||
return {
|
||||
columns,
|
||||
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
|
||||
hideTitle: cellWidth < 64 || cellHeight < 40,
|
||||
minimumItemSize: usesHorizontalItems ? 48 : 96,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BookmarksWidget({
|
||||
options,
|
||||
itemId,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
@@ -41,10 +91,15 @@ export default function BookmarksWidget({ options, itemId, width, displayMode }:
|
||||
[data],
|
||||
);
|
||||
|
||||
const compactLayout = getCompactBookmarkLayout(width, height, data.length, options.layout);
|
||||
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
|
||||
const compactHideHostname =
|
||||
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="sm">
|
||||
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
|
||||
{options.title.length > 0 && (
|
||||
<Title order={4} px="0.25rem">
|
||||
<Title order={4} px="0.25rem" lineClamp={1}>
|
||||
{options.title}
|
||||
</Title>
|
||||
)}
|
||||
@@ -57,29 +112,37 @@ export default function BookmarksWidget({ options, itemId, width, displayMode }:
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
{displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
|
||||
<GridLayout
|
||||
data={data}
|
||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
||||
hideTitle={options.hideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={options.hideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
{displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
|
||||
<FlexLayout
|
||||
data={data}
|
||||
direction={options.layout}
|
||||
hideTitle={options.hideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={options.hideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
{displayMode !== "advanced" && (
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box mih="100%">
|
||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
||||
<GridLayout
|
||||
data={data}
|
||||
columns={compactLayout.columns}
|
||||
minimumItemHeight={compactLayout.minimumItemSize}
|
||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
||||
hideTitle={compactHideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={compactHideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
) : (
|
||||
<FlexLayout
|
||||
data={data}
|
||||
direction={options.layout}
|
||||
minimumItemSize={compactLayout.minimumItemSize}
|
||||
hideTitle={compactHideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={compactHideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -94,6 +157,7 @@ interface FlexLayoutProps {
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
minimumItemSize: number;
|
||||
}
|
||||
|
||||
const FlexLayout = ({
|
||||
@@ -105,13 +169,15 @@ const FlexLayout = ({
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
minimumItemSize,
|
||||
}: FlexLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Flex direction={direction} gap="0" w="100%">
|
||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
||||
{data.map((app) => (
|
||||
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
@@ -188,6 +254,7 @@ const AdvancedBookmarksLayout = ({
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
@@ -233,6 +300,8 @@ interface GridLayoutProps {
|
||||
withBorder: boolean;
|
||||
itemDirection: "horizontal" | "vertical";
|
||||
hasIconColor: boolean;
|
||||
columns: number;
|
||||
minimumItemHeight: number;
|
||||
}
|
||||
|
||||
const GridLayout = ({
|
||||
@@ -244,19 +313,29 @@ const GridLayout = ({
|
||||
withBorder,
|
||||
itemDirection,
|
||||
hasIconColor,
|
||||
columns,
|
||||
minimumItemHeight,
|
||||
}: GridLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing={4}
|
||||
verticalSpacing={4}
|
||||
miw="100%"
|
||||
mih="100%"
|
||||
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
|
||||
>
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
flex="1"
|
||||
h="100%"
|
||||
>
|
||||
<Card
|
||||
h="100%"
|
||||
@@ -285,7 +364,7 @@ const GridLayout = ({
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -305,7 +384,7 @@ const VerticalItem = ({
|
||||
return (
|
||||
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
|
||||
{!hideTitle && (
|
||||
<Text fw={700} ta="center" size="xs">
|
||||
<Text fw={700} ta="center" size="xs" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
@@ -326,7 +405,7 @@ const VerticalItem = ({
|
||||
/>
|
||||
)}
|
||||
{!hideHostname && (
|
||||
<Anchor ta="center" component="span" size="xs">
|
||||
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
@@ -365,7 +444,7 @@ const HorizontalItem = ({
|
||||
)}
|
||||
{!(hideTitle && hideHostname) && (
|
||||
<>
|
||||
<Stack justify="space-between" gap={0}>
|
||||
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
|
||||
{!hideTitle && (
|
||||
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
|
||||
{app.name}
|
||||
@@ -373,7 +452,7 @@ const HorizontalItem = ({
|
||||
)}
|
||||
|
||||
{!hideHostname && (
|
||||
<Anchor component="span" size="xs">
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
|
||||
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
|
||||
|
||||
describe("bookmark display helpers", () => {
|
||||
test("uses bounded advanced columns", () => {
|
||||
@@ -9,6 +9,22 @@ describe("bookmark display helpers", () => {
|
||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test("uses container geometry to keep compact cards legible", () => {
|
||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
||||
columns: 2,
|
||||
hideHostname: false,
|
||||
hideTitle: false,
|
||||
minimumItemSize: 48,
|
||||
});
|
||||
|
||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
||||
columns: 4,
|
||||
hideHostname: true,
|
||||
hideTitle: true,
|
||||
minimumItemSize: 96,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
|
||||
|
||||
@@ -24,11 +24,17 @@ import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
events: CalendarEvent[];
|
||||
fillHeight?: boolean;
|
||||
groupByDate?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
|
||||
export const CalendarEventList = ({
|
||||
events,
|
||||
fillHeight = false,
|
||||
groupByDate = false,
|
||||
locale,
|
||||
}: CalendarEventListProps) => {
|
||||
const headingIdPrefix = useId();
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
@@ -43,9 +49,10 @@ export const CalendarEventList = ({ events, groupByDate = false, locale }: Calen
|
||||
offsetScrollbars
|
||||
pt={5}
|
||||
w="100%"
|
||||
h={fillHeight ? "100%" : undefined}
|
||||
styles={{
|
||||
viewport: {
|
||||
maxHeight: 450,
|
||||
maxHeight: fillHeight ? undefined : 450,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -100,9 +107,9 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Stack style={{ flexGrow: 1, minWidth: 0 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="wrap">
|
||||
<Stack gap={0} style={{ flex: "1 1 12rem", minWidth: 0 }}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
@@ -120,7 +127,7 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<Group gap={3} wrap="nowrap" align={"center"} ml="auto">
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
|
||||
export const moveCalendarMonth = (month: Date, amount: number): Date =>
|
||||
dayjs(month).startOf("month").add(amount, "month").toDate();
|
||||
|
||||
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
|
||||
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, moveCalendarMonth, splitEvents } from "./calendar-events";
|
||||
|
||||
describe("splitEvents should split multi-day events into multiple single-day events", () => {
|
||||
test("2 day all-day event should be split up into two all-day events", () => {
|
||||
@@ -92,6 +92,13 @@ describe("getCalendarAgendaEvents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveCalendarMonth", () => {
|
||||
test("uses the first day so short months do not skip", () => {
|
||||
expect(moveCalendarMonth(new Date(2025, 0, 31), 1)).toEqual(new Date(2025, 1, 1));
|
||||
expect(moveCalendarMonth(new Date(2025, 2, 31), -1)).toEqual(new Date(2025, 1, 1));
|
||||
});
|
||||
});
|
||||
|
||||
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
|
||||
title: "Test",
|
||||
subTitle: null,
|
||||
|
||||
@@ -1,31 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Box, Group, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
|
||||
import {
|
||||
getCalendarAgendaEvents,
|
||||
groupEventsByDate,
|
||||
moveCalendarMonth,
|
||||
splitEvents,
|
||||
toCalendarDateKey,
|
||||
} from "./calendar-events";
|
||||
import type { CalendarEventWithSource } from "./calendar-event-list";
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const runtimeInput = {
|
||||
integrationIds: props.integrationIds,
|
||||
month: month.getMonth(),
|
||||
year: month.getFullYear(),
|
||||
releaseType: props.options.releaseType,
|
||||
showUnmonitored: props.options.showUnmonitored,
|
||||
};
|
||||
useWidgetRuntimeQueries(
|
||||
props.widgetRuntimeRef,
|
||||
props.integrationIds.length > 0
|
||||
? [getQueryKey(clientApi.widget.calendar.findAllEvents, runtimeInput, "query")]
|
||||
: [],
|
||||
);
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
setWidgetRuntimeQueries(props.widgetStateRef, []);
|
||||
return <CalendarBase {...props} events={[]} failedIntegrations={[]} month={month} setMonth={setMonth} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
{...props}
|
||||
events={[]}
|
||||
failedIntegrations={[]}
|
||||
isPending={false}
|
||||
queryError={undefined}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
|
||||
@@ -36,15 +69,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({
|
||||
month,
|
||||
setMonth,
|
||||
isEditMode,
|
||||
integrationIds,
|
||||
options,
|
||||
displayMode,
|
||||
widgetStateRef,
|
||||
}: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -52,10 +77,24 @@ const FetchCalendar = ({
|
||||
releaseType: options.releaseType,
|
||||
showUnmonitored: options.showUnmonitored,
|
||||
};
|
||||
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "calendar", "findAllEvents"], input }]);
|
||||
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
const data = getUsableWidgetQueryData(calendarQuery);
|
||||
const { isPending } = calendarQuery;
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
const events = useMemo(
|
||||
() =>
|
||||
data?.flatMap(({ events: integrationEvents, integration, error }) =>
|
||||
error
|
||||
? []
|
||||
: integrationEvents.map(
|
||||
(event): CalendarEventWithSource => ({
|
||||
...event,
|
||||
source: { integrationId: integration.id, integrationName: integration.name },
|
||||
}),
|
||||
),
|
||||
) ?? [],
|
||||
[data],
|
||||
);
|
||||
const failedIntegrations =
|
||||
data?.flatMap(({ integration, error }) =>
|
||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||
@@ -66,6 +105,8 @@ const FetchCalendar = ({
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
queryError={calendarQuery.error}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
@@ -76,8 +117,10 @@ const FetchCalendar = ({
|
||||
|
||||
interface CalendarBaseProps {
|
||||
isEditMode: boolean;
|
||||
events: CalendarEvent[];
|
||||
events: CalendarEventWithSource[];
|
||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||
isPending: boolean;
|
||||
queryError: unknown;
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
@@ -88,22 +131,26 @@ const CalendarBase = ({
|
||||
isEditMode,
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
queryError,
|
||||
month,
|
||||
setMonth,
|
||||
options,
|
||||
displayMode,
|
||||
}: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const t = useI18n();
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const mantineTheme = useMantineTheme();
|
||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||
const { ref, width, height } = useElementSize();
|
||||
const isSmall = width < 256;
|
||||
const calendarHeight = displayMode === "advanced" ? height * 0.62 : height;
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
const normalizedEvents = useMemo(
|
||||
() => (displayMode === "advanced" ? [] : splitEvents(events)),
|
||||
[displayMode, events],
|
||||
);
|
||||
const visibleEvents = useMemo(
|
||||
() =>
|
||||
normalizedEvents.filter(
|
||||
@@ -113,15 +160,32 @@ const CalendarBase = ({
|
||||
);
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
|
||||
const agendaEvents = useMemo(
|
||||
() => getCalendarAgendaEvents(events, month, options.releaseType),
|
||||
[events, month, options.releaseType],
|
||||
() => (displayMode === "advanced" ? getCalendarAgendaEvents(events, month, options.releaseType) : []),
|
||||
[displayMode, events, month, options.releaseType],
|
||||
);
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
return (
|
||||
<CalendarAgenda
|
||||
events={agendaEvents}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
queryError={queryError}
|
||||
queryErrorLabel={t("widget.calendar.name")}
|
||||
isEditMode={isEditMode}
|
||||
locale={locale}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
||||
{failedIntegrations.length > 0 && (
|
||||
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
|
||||
<Group px="xs" justify="flex-end">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={t("widget.calendar.name")} />
|
||||
</Group>
|
||||
)}
|
||||
<Calendar
|
||||
@@ -137,7 +201,7 @@ const CalendarBase = ({
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h={displayMode === "advanced" ? "62%" : "100%"}
|
||||
h="100%"
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
@@ -191,19 +255,99 @@ const CalendarBase = ({
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={calendarHeight}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{displayMode === "advanced" && (
|
||||
<Box px="md" pb="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
|
||||
</Text>
|
||||
<CalendarEventList events={agendaEvents} groupByDate locale={locale} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarAgendaProps {
|
||||
events: CalendarEventWithSource[];
|
||||
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
||||
isPending: boolean;
|
||||
queryError: unknown;
|
||||
queryErrorLabel: string;
|
||||
isEditMode: boolean;
|
||||
locale: string;
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const CalendarAgenda = ({
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
queryError,
|
||||
queryErrorLabel,
|
||||
isEditMode,
|
||||
locale,
|
||||
month,
|
||||
setMonth,
|
||||
}: CalendarAgendaProps) => {
|
||||
const t = useScopedI18n("widget.calendar.advanced");
|
||||
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
|
||||
|
||||
return (
|
||||
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("previousMonth")}
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, -1))}
|
||||
>
|
||||
<IconChevronLeft size={18} aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text component="h3" size="sm" fw={600} tt="capitalize" lineClamp={1}>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{isPending ? t("loading") : t("eventCount", { count: events.length })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Tooltip label={t("nextMonth")} events={{ hover: true, focus: true, touch: false }}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("nextMonth")}
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, 1))}
|
||||
>
|
||||
<IconChevronRight size={18} aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
{isPending ? (
|
||||
<Center component="output" h="100%" aria-label={t("loading")}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : events.length > 0 ? (
|
||||
<CalendarEventList events={events} advanced groupByDate locale={locale} fillHeight />
|
||||
) : (
|
||||
<Stack h="100%" align="center" justify="center" p="md">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("noEvents")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
@@ -31,6 +32,7 @@ export default function CoolifyWidget({
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
@@ -40,16 +42,31 @@ interface CoolifyContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
|
||||
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
|
||||
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 144);
|
||||
const hideFooter = !isAdvanced && height < 112;
|
||||
const [firstInstance] = instancesData;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (instancesData.length === 1 && firstInstance) {
|
||||
return (
|
||||
<SingleInstanceLayout
|
||||
@@ -58,6 +75,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +91,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
import type { CoolifyOptions, InstanceData } from "./types";
|
||||
@@ -18,12 +19,14 @@ interface InstanceCardProps {
|
||||
instance: InstanceData;
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
|
||||
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const cardKey = `${widgetKey}-${instance.integrationId}`;
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${cardKey}`,
|
||||
@@ -33,44 +36,41 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
key: `coolify-sections-${cardKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
instance.instanceInfo.applications,
|
||||
instance.instanceInfo.services,
|
||||
);
|
||||
|
||||
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
|
||||
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
|
||||
const runningApps = instance.instanceInfo.applications.filter(
|
||||
(a) => parseStatus(a.status ?? "") === "running",
|
||||
).length;
|
||||
const runningServices = instance.instanceInfo.services.filter(
|
||||
(s) => parseStatus(s.status ?? "") === "running",
|
||||
).length;
|
||||
const healthyResources =
|
||||
(options.showServers ? onlineServers : 0) +
|
||||
(options.showApplications ? runningApps : 0) +
|
||||
(options.showServices ? runningServices : 0);
|
||||
const totalResources =
|
||||
(options.showServers ? instance.instanceInfo.servers.length : 0) +
|
||||
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
|
||||
(options.showServices ? instance.instanceInfo.services.length : 0);
|
||||
const resourceSummary = [
|
||||
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
|
||||
options.showApplications
|
||||
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
|
||||
: null,
|
||||
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card p={0} radius="sm">
|
||||
@@ -80,19 +80,42 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
wrap="nowrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "10px" : "xs"} fw={600} c="inherit" lineClamp={1}>
|
||||
{instance.integrationName}
|
||||
</Anchor>
|
||||
<Stack gap={0} miw={0}>
|
||||
<Anchor
|
||||
component={baseUrl ? "a" : "span"}
|
||||
href={baseUrl}
|
||||
target={baseUrl ? "_blank" : undefined}
|
||||
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "10px" : "xs"}
|
||||
fw={600}
|
||||
c="inherit"
|
||||
lineClamp={1}
|
||||
>
|
||||
{instance.integrationName}
|
||||
</Anchor>
|
||||
{isAdvanced && (
|
||||
<Text fz="10px" c="dimmed" truncate="end">
|
||||
{t("source.url", { url: displayUrl })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{options.showServers && (
|
||||
{isTiny && totalResources > 0 ? (
|
||||
<Tooltip label={resourceSummary}>
|
||||
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
|
||||
{healthyResources}/{totalResources}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isTiny && options.showServers && (
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
|
||||
{onlineServers}/{instance.instanceInfo.servers.length}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showApplications && (
|
||||
{!isTiny && options.showApplications && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
|
||||
@@ -101,7 +124,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
{runningApps}/{instance.instanceInfo.applications.length}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showServices && (
|
||||
{!isTiny && options.showServices && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
|
||||
@@ -126,6 +149,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
serverResourceCounts={serverResourceCounts}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
showIp={showIp}
|
||||
onToggleIp={() => setShowIp((prev) => !prev)}
|
||||
/>
|
||||
@@ -134,18 +158,29 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
|
||||
)}
|
||||
{options.showServices && (
|
||||
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
|
||||
<ServicesSection
|
||||
services={instance.instanceInfo.services}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<Group justify="space-between" p={4} style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
|
||||
import { IconFileText, IconLink } from "@tabler/icons-react";
|
||||
|
||||
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
|
||||
|
||||
@@ -39,7 +39,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} color={statusColor} />
|
||||
{resourceUrl ? (
|
||||
<Anchor href={resourceUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
|
||||
<Anchor
|
||||
href={resourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "8px" : "xs"}
|
||||
c="inherit"
|
||||
lineClamp={1}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
@@ -50,24 +57,39 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
</Group>
|
||||
<Group wrap="nowrap" gap={4} ml={16}>
|
||||
{cleanFqdn(item.fqdn) && (
|
||||
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={cleanFqdn(item.fqdn)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${item.name}`}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconLink size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{resourceUrl && (
|
||||
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
<IconExternalLink size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{logsUrl && (
|
||||
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
{!isTiny && logsUrl && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={logsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${item.name} logs`}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconFileText size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
{getResourceTimestamp(item, resourceType) && (
|
||||
{!isTiny && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
)}
|
||||
{!isTiny && getResourceTimestamp(item, resourceType) && (
|
||||
<Text fz="10px" c="dimmed" ml="auto">
|
||||
{getResourceTimestamp(item, resourceType)}
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
import { IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
|
||||
import type { CoolifyServer } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -40,6 +40,7 @@ export function ServersSection({
|
||||
</Badge>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
@@ -96,7 +97,14 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
<Stack gap={0}>
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} color={isOnline ? "green" : "red"} />
|
||||
<Anchor href={serverUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
|
||||
<Anchor
|
||||
href={serverUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "8px" : "xs"}
|
||||
c="inherit"
|
||||
lineClamp={1}
|
||||
>
|
||||
{server.name}
|
||||
</Anchor>
|
||||
{isBuildServer ? (
|
||||
@@ -110,9 +118,6 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
)}
|
||||
</Group>
|
||||
<Group wrap="nowrap" gap={4} ml={16}>
|
||||
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
<IconExternalLink size={12} />
|
||||
</ActionIcon>
|
||||
<Text fz="10px" c="dimmed">
|
||||
{showIp ? server.ip : "***.***.***.***"}
|
||||
</Text>
|
||||
|
||||
@@ -20,9 +20,17 @@ interface SingleInstanceLayoutProps {
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isAdvanced }: SingleInstanceLayoutProps) {
|
||||
export function SingleInstanceLayout({
|
||||
instance,
|
||||
options,
|
||||
isTiny,
|
||||
widgetKey,
|
||||
isAdvanced,
|
||||
hideFooter,
|
||||
}: SingleInstanceLayoutProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${widgetKey}`,
|
||||
@@ -74,7 +82,15 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
|
||||
oolify
|
||||
</Text>
|
||||
</Group>
|
||||
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "xs" : "sm"} fw={500} c="dimmed" lineClamp={1}>
|
||||
<Anchor
|
||||
href={baseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{displayUrl}
|
||||
</Anchor>
|
||||
</Group>
|
||||
@@ -104,21 +120,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Text size="xs" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function DnsHoleControlsWidget({
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => integrationIds.includes(id));
|
||||
|
||||
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
const { data: summaries = [], isPending: isSummaryPending } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -129,6 +129,16 @@ export default function DnsHoleControlsWidget({
|
||||
setBulkPending(false);
|
||||
};
|
||||
|
||||
if (isSummaryPending) {
|
||||
return (
|
||||
<Stack h="100%" justify="center" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("common.action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="dns-hole-controls-stack"
|
||||
@@ -142,6 +152,7 @@ export default function DnsHoleControlsWidget({
|
||||
<Flex className="dns-hole-controls-buttons" gap="sm">
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||
<Button
|
||||
aria-label={t("widget.dnsHoleControls.controls.enableAll")}
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-enable-all-button"
|
||||
@@ -159,6 +170,7 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
|
||||
<Button
|
||||
aria-label={t("widget.dnsHoleControls.controls.setTimer")}
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-timer-all-button"
|
||||
@@ -179,6 +191,7 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
|
||||
<Button
|
||||
aria-label={t("widget.dnsHoleControls.controls.disableAll")}
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-disable-all-button"
|
||||
@@ -340,6 +353,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
<Group gap="xs" grow wrap="nowrap" w="100%">
|
||||
{!isEnabled ? (
|
||||
<ActionIcon
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.enableAll")}: ${data.integration.name}`}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
disabled={!controlEnabled}
|
||||
size="sm"
|
||||
@@ -350,6 +364,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.disableAll")}: ${data.integration.name}`}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
disabled={!controlEnabled}
|
||||
size="sm"
|
||||
@@ -360,6 +375,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
|
||||
onClick={() => {
|
||||
setSelectedIntegrationIds([data.integration.id]);
|
||||
open();
|
||||
@@ -376,6 +392,11 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
)}
|
||||
{layout === "md" && (
|
||||
<UnstyledButton
|
||||
aria-label={`${
|
||||
isEnabled
|
||||
? t("widget.dnsHoleControls.controls.disableAll")
|
||||
: t("widget.dnsHoleControls.controls.enableAll")
|
||||
}: ${data.integration.name}`}
|
||||
className="dns-hole-controls-item-toggle-button"
|
||||
disabled={!controlEnabled}
|
||||
display="contents"
|
||||
@@ -418,6 +439,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</Flex>
|
||||
{layout === "md" && (
|
||||
<ActionIcon
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
|
||||
className="dns-hole-controls-item-timer-button"
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function DnsHoleSummaryWidget({
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
const { data: summaries = [], isPending } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
@@ -45,6 +45,16 @@ export default function DnsHoleSummaryWidget({
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const layoutProps = boxPropsByLayout(options.layout);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack h="100%" justify="center" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("common.action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||
@@ -53,7 +63,16 @@ export default function DnsHoleSummaryWidget({
|
||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||
))
|
||||
) : (
|
||||
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
|
||||
<Stack
|
||||
aria-live="polite"
|
||||
h="100%"
|
||||
w="100%"
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="sm"
|
||||
p="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
<AvatarGroup spacing="md">
|
||||
{summaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
@@ -154,11 +173,15 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
|
||||
const tooltip = item.tooltip?.(data, t);
|
||||
const board = useRequiredBoard();
|
||||
const label = translateIfNecessary(t, item.label);
|
||||
const value = item.value(data, width <= 64 ? "sm" : "md");
|
||||
|
||||
return (
|
||||
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
|
||||
<Card
|
||||
ref={ref}
|
||||
component="section"
|
||||
aria-label={`${label}: ${value}`}
|
||||
className="summary-card"
|
||||
p="sm"
|
||||
radius={board.itemRadius}
|
||||
@@ -190,11 +213,11 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text className="summary-card-value text-flash" ta="center" size="lg" fw="bold" maw="100%">
|
||||
{item.value(data, width <= 64 ? "sm" : "md")}
|
||||
{value}
|
||||
</Text>
|
||||
{!hideLabel && (
|
||||
<Text className="summary-card-label" ta="center" size="xs" maw="100%">
|
||||
{translateIfNecessary(t, item.label)}
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDockerColumnVisibility } from "./layout";
|
||||
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
|
||||
|
||||
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("getDockerColumnVisibility", () => {
|
||||
test("keeps only essential columns in a narrow compact widget", () => {
|
||||
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
|
||||
name: true,
|
||||
state: true,
|
||||
state: false,
|
||||
host: false,
|
||||
cpuUsage: false,
|
||||
memoryUsage: false,
|
||||
@@ -17,13 +17,17 @@ describe("getDockerColumnVisibility", () => {
|
||||
});
|
||||
|
||||
test("reveals compact metrics as width becomes available", () => {
|
||||
expect(getDockerColumnVisibility(columns, 380, false)).toMatchObject({
|
||||
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
|
||||
host: false,
|
||||
cpuUsage: true,
|
||||
memoryUsage: true,
|
||||
memoryUsage: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a configured state column when there is no name column for the inline dot", () => {
|
||||
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
|
||||
});
|
||||
|
||||
test("uses every expert column in advanced mode", () => {
|
||||
expect(getDockerColumnVisibility([], 240, true)).toEqual({
|
||||
name: true,
|
||||
@@ -35,3 +39,14 @@ describe("getDockerColumnVisibility", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDockerFooterVisibility", () => {
|
||||
test("reveals totals only when they fit", () => {
|
||||
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
|
||||
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
|
||||
});
|
||||
|
||||
test("keeps every total in advanced mode", () => {
|
||||
expect(getDockerFooterVisibility(240, true)).toEqual({ footer: true, cpu: true, memory: true });
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,16 @@ export const getDockerColumnVisibility = (
|
||||
|
||||
return {
|
||||
name: isVisible("name"),
|
||||
state: isVisible("state"),
|
||||
host: isVisible("host") && (isAdvanced || width >= 440),
|
||||
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 280),
|
||||
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 360),
|
||||
state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")),
|
||||
host: isVisible("host") && (isAdvanced || width >= 640),
|
||||
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420),
|
||||
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520),
|
||||
actions: isVisible("actions"),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({
|
||||
footer: isAdvanced || width > 256,
|
||||
cpu: isAdvanced || width >= 360,
|
||||
memory: isAdvanced || width >= 480,
|
||||
});
|
||||
@@ -50,12 +50,15 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
|
||||
export default function FirewallWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
itemId,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"firewall">) {
|
||||
const [selectedFirewall, setSelectedFirewall] = useState("");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 180);
|
||||
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
|
||||
const showInterfaces = isAdvanced || height >= 120;
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelect = useCallback((value: string | null) => {
|
||||
@@ -102,6 +105,17 @@ export default function FirewallWidget({
|
||||
value: firewallId,
|
||||
}));
|
||||
|
||||
if (firewallIds.length === 0) {
|
||||
const isLoading = queries.some((query) => query.isPending || query.isFetching);
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{isLoading ? t("common.action.loading") : t("widget.firewall.empty.noInterfaces")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p={isAdvanced ? "xs" : 0}>
|
||||
@@ -146,6 +160,8 @@ export default function FirewallWidget({
|
||||
hasError={hasError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
ringSize={ringSize}
|
||||
showInterfaces={showInterfaces}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={t("widget.firewall.error.internalServerError")}
|
||||
@@ -175,6 +191,8 @@ interface FirewallPanelProps {
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
ringSize: number;
|
||||
showInterfaces: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
errorLabel: string;
|
||||
@@ -197,6 +215,8 @@ const FirewallPanel = ({
|
||||
hasError,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
ringSize,
|
||||
showInterfaces,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
errorLabel,
|
||||
@@ -237,33 +257,35 @@ const FirewallPanel = ({
|
||||
|
||||
<Flex justify="center" align="center" wrap="wrap">
|
||||
{cpu !== undefined && (
|
||||
<MetricRing value={cpu} icon={IconCpu} isTiny={isTiny} label={t("widget.firewall.widget.cpu")} t={t} />
|
||||
<MetricRing value={cpu} icon={IconCpu} size={ringSize} label={t("widget.firewall.widget.cpu")} t={t} />
|
||||
)}
|
||||
{memory !== undefined && (
|
||||
<MetricRing
|
||||
value={memory}
|
||||
icon={IconBrain}
|
||||
isTiny={isTiny}
|
||||
size={ringSize}
|
||||
label={t("widget.firewall.widget.memory")}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<InterfacesPanel
|
||||
summary={interfaces ?? []}
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
errorBadgeLabel={errorBadgeLabel}
|
||||
noDataLabel={noDataLabel}
|
||||
loadingLabel={loadingLabel}
|
||||
label={interfacesLabel}
|
||||
/>
|
||||
{showInterfaces && (
|
||||
<InterfacesPanel
|
||||
summary={interfaces ?? []}
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
errorBadgeLabel={errorBadgeLabel}
|
||||
noDataLabel={noDataLabel}
|
||||
loadingLabel={loadingLabel}
|
||||
label={interfacesLabel}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
@@ -271,12 +293,12 @@ const FirewallPanel = ({
|
||||
interface MetricRingProps {
|
||||
value: number;
|
||||
icon: typeof IconCpu;
|
||||
isTiny: boolean;
|
||||
size: number;
|
||||
label: string;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
|
||||
const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
const status = getMetricStatus(safeValue);
|
||||
const statusLabel = t(`widget.firewall.status.${status}`);
|
||||
@@ -289,13 +311,13 @@ const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) =>
|
||||
status: statusLabel,
|
||||
})}
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
size={size}
|
||||
thickness={size < 72 ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
|
||||
<Icon size={isTiny ? 8 : 16} />
|
||||
{!isTiny && <Text size="8px">{statusLabel}</Text>}
|
||||
<Text size={size < 72 ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
|
||||
<Icon size={size < 72 ? 8 : 16} />
|
||||
{size >= 72 && <Text size="8px">{statusLabel}</Text>}
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
|
||||
@@ -10,16 +10,18 @@ interface FirewallMenuProps {
|
||||
}
|
||||
|
||||
export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => (
|
||||
<Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Select
|
||||
value={selectedFirewall}
|
||||
onChange={onChange}
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
size="xs"
|
||||
w="100%"
|
||||
color="lightgray"
|
||||
data={dropdownItems}
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: "24px",
|
||||
paddingInline: isTiny ? 6 : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,8 +29,9 @@ export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny
|
||||
autoContrast
|
||||
variant="outline"
|
||||
color="lightgray"
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
style={{ minHeight: "24px" }}
|
||||
size="xs"
|
||||
style={{ minHeight: "24px", maxWidth: isTiny ? 72 : undefined }}
|
||||
styles={{ label: { overflow: "hidden", textOverflow: "ellipsis" } }}
|
||||
>
|
||||
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
|
||||
</Badge>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ClusterHealthMonitoring = ({
|
||||
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
|
||||
{options.showUptime && (
|
||||
<Group justify="center" wrap="nowrap">
|
||||
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
|
||||
<Text fz={isTiny ? 8 : "xs"} fw={700} c="dimmed" ta="center">
|
||||
{formatUptime(uptime, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -5,3 +5,35 @@
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
|
||||
.systemPanel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.infoAction {
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.infoActionCompact {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.systemPanel:hover .infoActionCompact,
|
||||
.systemPanel:focus-within .infoActionCompact {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.infoActionCompact {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.infoAction {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -83,16 +83,29 @@ export const SystemHealthMonitoring = ({
|
||||
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
|
||||
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
|
||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
||||
const hasAttentionState = healthInfo.rebootRequired || healthInfo.availablePkgUpdates > 0;
|
||||
return (
|
||||
<Stack
|
||||
gap="sm"
|
||||
key={integrationId}
|
||||
h={!isAdvanced && healthData.length === 1 ? "100%" : "auto"}
|
||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||
className={combineClasses(
|
||||
`health-monitoring-information health-monitoring-${integrationName}`,
|
||||
classes.systemPanel,
|
||||
)}
|
||||
p="sm"
|
||||
pos="relative"
|
||||
>
|
||||
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
|
||||
<Box
|
||||
className={combineClasses(
|
||||
"health-monitoring-information-card-section",
|
||||
classes.infoAction,
|
||||
!isAdvanced && !hasAttentionState && classes.infoActionCompact,
|
||||
)}
|
||||
pos="absolute"
|
||||
top={8}
|
||||
right={8}
|
||||
>
|
||||
<Indicator
|
||||
className="health-monitoring-updates-reboot-indicator"
|
||||
inline
|
||||
|
||||
@@ -24,29 +24,42 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dots {
|
||||
.carouselControls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
inset-block-start: 8px;
|
||||
inset-inline-end: 8px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-2px);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
.carouselContainer:hover .carouselControls,
|
||||
.carouselContainer:focus-within .carouselControls,
|
||||
.carouselControls[data-visible="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
@media (any-pointer: coarse) {
|
||||
.carouselControls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
.carouselControls button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.carouselControls {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -161,41 +161,48 @@ function Carousel({
|
||||
<Box w="100%" style={{ flex: 1, minHeight: 0 }} className={classes.carouselContainer}>
|
||||
<Image src={currentAsset.publicLink} alt={t("albumPhoto")} className={classes.carouselImage} />
|
||||
|
||||
{advanced && (
|
||||
<>
|
||||
<ActionIcon
|
||||
aria-label={t("actions.previousPhoto")}
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
left="md"
|
||||
top="50%"
|
||||
onClick={() => move(-1)}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
<Group pos="absolute" right="md" top="md" gap="xs">
|
||||
{assets.length > 1 && (
|
||||
<Group
|
||||
className={classes.carouselControls}
|
||||
data-visible={advanced || paused || undefined}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
>
|
||||
{advanced && (
|
||||
<ActionIcon
|
||||
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
|
||||
aria-label={t("actions.previousPhoto")}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
onClick={() => setPaused((value) => !value)}
|
||||
size={40}
|
||||
onClick={() => move(-1)}
|
||||
>
|
||||
{paused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
|
||||
<IconChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
<ActionIcon
|
||||
aria-label={t("actions.nextPhoto")}
|
||||
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
right="md"
|
||||
top="50%"
|
||||
onClick={() => move(1)}
|
||||
size={advanced ? 40 : 32}
|
||||
onClick={() => setPaused((value) => !value)}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
{paused ? <IconPlayerPlay size={18} /> : <IconPlayerPause size={18} />}
|
||||
</ActionIcon>
|
||||
</>
|
||||
{advanced && (
|
||||
<ActionIcon
|
||||
aria-label={t("actions.nextPhoto")}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={40}
|
||||
onClick={() => move(1)}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{(showPhotoInfo || advanced) && (
|
||||
|
||||
@@ -8,3 +8,11 @@
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.statItemDense {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export default function ImmichServerStatsWidget({
|
||||
options,
|
||||
displayMode = "compact",
|
||||
width,
|
||||
height,
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"immich-serverStats">) {
|
||||
const t = useI18n();
|
||||
@@ -39,16 +40,26 @@ export default function ImmichServerStatsWidget({
|
||||
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
const statCount =
|
||||
Number(options.showUsers) + Number(options.showPhotos) + Number(options.showVideos) + Number(options.showStorage);
|
||||
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
|
||||
|
||||
const statsContent = (
|
||||
<SimpleGrid cols={isAdvanced ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1} spacing="sm">
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{options.showUsers && (
|
||||
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
|
||||
<StatItem
|
||||
icon={<IconUsers size={20} />}
|
||||
label={t("widget.immich-serverStats.users")}
|
||||
value={stats.userCount}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{options.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size={20} />}
|
||||
label={t("widget.immich-serverStats.photos")}
|
||||
value={stats.photoCount}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{options.showVideos && (
|
||||
@@ -56,6 +67,7 @@ export default function ImmichServerStatsWidget({
|
||||
icon={<IconVideo size={20} />}
|
||||
label={t("widget.immich-serverStats.videos")}
|
||||
value={stats.videoCount}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{options.showStorage && (
|
||||
@@ -63,6 +75,7 @@ export default function ImmichServerStatsWidget({
|
||||
icon={<IconDatabase size={20} />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
@@ -70,7 +83,7 @@ export default function ImmichServerStatsWidget({
|
||||
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
<Stack gap="md" h="100%" p="md" justify="center">
|
||||
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center">
|
||||
{statsContent}
|
||||
</Stack>
|
||||
);
|
||||
@@ -106,20 +119,46 @@ interface StatItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
dense: boolean;
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value }: StatItemProps) {
|
||||
function StatItem({ icon, label, value, dense }: StatItemProps) {
|
||||
if (dense) {
|
||||
return (
|
||||
<Stack gap={1} align="center" justify="center" className={classes.statItemDense} title={`${label}: ${value}`}>
|
||||
<Text size="sm" fw={700} truncate="end" w="100%" ta="center">
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate="end" w="100%" ta="center">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between" align="center" className={classes.statItem}>
|
||||
<Group gap="sm" align="center">
|
||||
<Group gap="sm" align="center" wrap="nowrap" miw={0}>
|
||||
{icon}
|
||||
<Text size="sm" fw={500}>
|
||||
<Text size="sm" fw={500} truncate="end">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="var(--mantine-primary-color)">
|
||||
<Text size="sm" fw={700} c="var(--mantine-primary-color)" truncate="end" title={String(value)}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => {
|
||||
const count = Math.max(1, itemCount);
|
||||
if (isAdvanced) {
|
||||
return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false };
|
||||
}
|
||||
|
||||
const dense = width < 320 || height < 160;
|
||||
if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true };
|
||||
if (height < 220) return { columns: Math.min(count, 2), dense: true };
|
||||
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getImmichStatsLayout } from "./component";
|
||||
|
||||
describe("Immich server statistics compact layout", () => {
|
||||
test("keeps four enabled stats legible in a short widget", () => {
|
||||
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true });
|
||||
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true });
|
||||
});
|
||||
|
||||
test("uses the roomier summary layout when space is available", () => {
|
||||
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false });
|
||||
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true });
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,44 @@
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.testAction {
|
||||
margin-inline-start: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.root:hover .testAction,
|
||||
.root:focus-within .testAction {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indexerRow {
|
||||
min-height: 24px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.testAction {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.testAction {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActionIcon, Anchor, Badge, Button, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function IndexerManagerWidget({
|
||||
const board = useRequiredBoard();
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const hasSmallWidth = !isAdvanced && width < 256;
|
||||
const hasSmallHeight = !isAdvanced && height < 256;
|
||||
const isDense = !isAdvanced && (width < 280 || height < 180);
|
||||
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
|
||||
const unavailableCount = allIndexers.filter(
|
||||
(indexer) => indexer.status === false || indexer.enabled === false,
|
||||
@@ -49,16 +49,26 @@ export default function IndexerManagerWidget({
|
||||
}, [integrationIds, testAll, widgetStateRef]);
|
||||
|
||||
return (
|
||||
<Flex className="indexer-manager-container" h="100%" direction="column" gap="sm" p="sm" align="center">
|
||||
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap">
|
||||
<IconReportSearch
|
||||
className="indexer-manager-title-icon"
|
||||
size={hasSmallWidth ? 16 : 20}
|
||||
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
|
||||
/>
|
||||
<Text size={hasSmallWidth ? "xs" : "md"} fw="bold">
|
||||
{t("widget.indexerManager.title")}
|
||||
</Text>
|
||||
<Flex
|
||||
className={`indexer-manager-container ${classes.root}`}
|
||||
h="100%"
|
||||
direction="column"
|
||||
gap={isDense ? 4 : "xs"}
|
||||
p={isDense ? "xs" : "sm"}
|
||||
>
|
||||
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
|
||||
<Tooltip label={t("widget.indexerManager.title")} disabled={!hasSmallWidth}>
|
||||
<IconReportSearch
|
||||
className="indexer-manager-title-icon"
|
||||
size={hasSmallWidth ? 16 : 20}
|
||||
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!hasSmallWidth && (
|
||||
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
|
||||
{t("widget.indexerManager.title")}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
@@ -71,9 +81,9 @@ export default function IndexerManagerWidget({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{hasSmallHeight && (
|
||||
<Tooltip label={t("widget.indexerManager.testAll")}>
|
||||
<ActionIcon
|
||||
className="indexer-manager-test-action-icon"
|
||||
className={combineClasses("indexer-manager-test-action-icon", classes.testAction)}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
@@ -83,15 +93,16 @@ export default function IndexerManagerWidget({
|
||||
onClick={() => {
|
||||
testAll({ integrationIds });
|
||||
}}
|
||||
aria-label={t("widget.indexerManager.testAll")}
|
||||
>
|
||||
<IconTestPipe size={12} />
|
||||
<IconTestPipe size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Card
|
||||
className={combineClasses("indexer-manager-list-container", classes.card)}
|
||||
w="100%"
|
||||
p="xs"
|
||||
p={isDense ? 4 : "xs"}
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
@@ -114,7 +125,7 @@ export default function IndexerManagerWidget({
|
||||
)}
|
||||
{indexers.map((indexer) => (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name}`}
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
|
||||
key={indexer.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
@@ -124,8 +135,15 @@ export default function IndexerManagerWidget({
|
||||
className="indexer-manager-line-anchor"
|
||||
href={indexer.url}
|
||||
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
|
||||
rel={options.openIndexerSiteInNewTab ? "noopener noreferrer" : undefined}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
<Text className="indexer-manager-line-anchor-text" c="dimmed" size={hasSmallWidth ? "xs" : "sm"}>
|
||||
<Text
|
||||
className="indexer-manager-line-anchor-text"
|
||||
c="dimmed"
|
||||
size={hasSmallWidth ? "xs" : "sm"}
|
||||
truncate="end"
|
||||
>
|
||||
{indexer.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
@@ -153,24 +171,6 @@ export default function IndexerManagerWidget({
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
{!hasSmallHeight && (
|
||||
<Button
|
||||
className="indexer-manager-test-button"
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
leftSection={<IconTestPipe size={"1rem"} />}
|
||||
loading={isPending}
|
||||
disabled={isEditMode}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAll({ integrationIds });
|
||||
}}
|
||||
>
|
||||
{t("widget.indexerManager.testAll")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
background-color 120ms ease;
|
||||
|
||||
@mixin dark {
|
||||
--border-color: var(--mantine-color-dark-4);
|
||||
@@ -17,22 +17,16 @@
|
||||
--border-color: var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.3);
|
||||
opacity: 0.16;
|
||||
pointer-events: none;
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -47,3 +41,9 @@
|
||||
object-fit: cover;
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-align: start;
|
||||
transition: background-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function MediaReleasesWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
|
||||
@@ -48,7 +50,7 @@ export default function MediaReleasesWidget({
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} />
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -60,25 +62,30 @@ interface ItemProps {
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
|
||||
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||
isAdvanced: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const formatReleaseDate = (value: unknown, locale: string) => {
|
||||
const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return "—";
|
||||
return Intl.DateTimeFormat(locale, {
|
||||
month: "2-digit",
|
||||
month: compact ? "short" : "2-digit",
|
||||
year: "numeric",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }),
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const Item = ({ item, options, isAdvanced }: ItemProps) => {
|
||||
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
const locale = useCurrentLocale();
|
||||
const t = useI18n();
|
||||
const length = formatLength(item.length, item.type, t);
|
||||
const isCompact = !isAdvanced && (width < 340 || height < 180);
|
||||
const isTiny = !isAdvanced && (width < 220 || height < 105);
|
||||
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
|
||||
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
|
||||
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
|
||||
|
||||
return (
|
||||
<TooltipFloating
|
||||
@@ -93,13 +100,15 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => {
|
||||
}
|
||||
>
|
||||
<UnstyledButton
|
||||
className={classes.item}
|
||||
component="a"
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
pos="relative"
|
||||
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
|
||||
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
|
||||
h="100%"
|
||||
title={item.title}
|
||||
style={
|
||||
isAdvanced
|
||||
? {
|
||||
@@ -126,57 +135,63 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group justify="space-between" h="100%" wrap="nowrap">
|
||||
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
|
||||
{(isAdvanced || options.layout === "poster") && (
|
||||
<Image w={isAdvanced ? 80 : 60} src={item.imageUrls.poster} alt={item.title} radius="xs" />
|
||||
<Group justify="space-between" h="100%" wrap="nowrap" gap={isCompact ? "xs" : "sm"}>
|
||||
<Group align="start" wrap="nowrap" style={{ zIndex: 0, minWidth: 0, flex: 1 }} gap="xs">
|
||||
{showPoster && (
|
||||
<Image
|
||||
w={isAdvanced ? 80 : isCompact ? 44 : 60}
|
||||
src={item.imageUrls.poster}
|
||||
alt=""
|
||||
radius="xs"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={4}>
|
||||
<Stack gap={isCompact ? 2 : 4} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw="bold" lineClamp={2}>
|
||||
<Text size={isTiny ? "xs" : "sm"} fw="bold" lineClamp={isCompact ? 1 : 2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.subtitle !== undefined && (
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{item.subtitle !== undefined && !isTiny && (
|
||||
<Text size={isCompact ? "xs" : "sm"} c={isCompact ? "dimmed" : undefined} lineClamp={1}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={6} style={{ rowGap: 0 }}>
|
||||
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
|
||||
{length !== undefined && (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale, isCompact)} />
|
||||
{length !== undefined && !isTiny && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
|
||||
</>
|
||||
)}
|
||||
{item.producer !== undefined && (
|
||||
{item.producer !== undefined && showExtendedMetadata && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info label={item.producer} />
|
||||
</>
|
||||
)}
|
||||
{item.rating !== undefined && (
|
||||
{item.rating !== undefined && showExtendedMetadata && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info icon={IconStarFilled} label={item.rating} />
|
||||
</>
|
||||
)}
|
||||
{item.price !== undefined && (
|
||||
{item.price !== undefined && showExtendedMetadata && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info label={`$${item.price.toFixed(2)}`} />
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
{item.tags.length > 0 && (
|
||||
{item.tags.length > 0 && (isAdvanced || !isCompact) && (
|
||||
<OverflowBadge
|
||||
size="xs"
|
||||
groupGap={4}
|
||||
data={item.tags}
|
||||
overflowCount={3}
|
||||
disablePopover={!isAdvanced}
|
||||
style={{ cursor: "pointer" }}
|
||||
style={{ cursor: isAdvanced ? "pointer" : undefined }}
|
||||
/>
|
||||
)}
|
||||
{isAdvanced && item.description && (
|
||||
@@ -186,7 +201,7 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => {
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
{(isAdvanced || options.showType || options.showSource) && (
|
||||
{showSide && (
|
||||
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
|
||||
{(isAdvanced || options.showType) && (
|
||||
<Badge
|
||||
@@ -223,9 +238,9 @@ const InfoDivider = () => (
|
||||
|
||||
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} />}
|
||||
<Text size="xs" c="gray.5">
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
.card {
|
||||
min-width: 0;
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.pendingActions {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.card:hover .pendingActions,
|
||||
.card:focus-within .pendingActions,
|
||||
.pendingActionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.pendingActions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.pendingActions button {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.pendingActions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
@@ -46,7 +47,7 @@ export default function MediaServerWidget({
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
<Stack className={classes.searchRoot} gap={0}>
|
||||
<Stack className={searchClasses.searchRoot} gap={0}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group px="sm" pt="xs">
|
||||
@@ -64,6 +65,7 @@ export default function MediaServerWidget({
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
|
||||
isDense={displayMode !== "advanced" && (width < 340 || height < 150)}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
|
||||
options={options}
|
||||
@@ -81,7 +83,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={classes.searchButton}
|
||||
className={searchClasses.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
@@ -96,33 +98,23 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
|
||||
isTiny: boolean;
|
||||
isDense: boolean;
|
||||
isAdvanced: boolean;
|
||||
canInteract: boolean;
|
||||
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
|
||||
}
|
||||
|
||||
const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
|
||||
const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status}`}
|
||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status} ${classes.card}`}
|
||||
radius={board.itemRadius}
|
||||
p="xs"
|
||||
p={isDense ? 6 : "xs"}
|
||||
withBorder
|
||||
>
|
||||
<Image
|
||||
className="mediaRequests-list-item-background"
|
||||
src={request.backdropImageUrl}
|
||||
pos="absolute"
|
||||
w="100%"
|
||||
h="100%"
|
||||
opacity={0.2}
|
||||
top={0}
|
||||
left={0}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<Group
|
||||
className="mediaRequests-list-item-contents"
|
||||
h="100%"
|
||||
@@ -131,20 +123,55 @@ const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }:
|
||||
wrap="nowrap"
|
||||
gap={0}
|
||||
>
|
||||
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1}>
|
||||
<Group
|
||||
className="mediaRequests-list-item-left-side"
|
||||
h="100%"
|
||||
gap={isDense ? "xs" : "md"}
|
||||
wrap="nowrap"
|
||||
flex={1}
|
||||
miw={0}
|
||||
>
|
||||
{!isTiny && (
|
||||
<Image
|
||||
className="mediaRequests-list-item-poster"
|
||||
src={request.posterImagePath}
|
||||
h={40}
|
||||
h={isDense ? 36 : 44}
|
||||
w="auto"
|
||||
radius={"md"}
|
||||
radius="sm"
|
||||
alt=""
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap={0} w="100%">
|
||||
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
|
||||
<Group gap="xs">
|
||||
<Stack gap={2} w="100%" miw={0}>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||
href={request.href}
|
||||
c="var(--mantine-color-text)"
|
||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={600}
|
||||
title={request.name}
|
||||
truncate="end"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{request.name || "unknown"}
|
||||
</Anchor>
|
||||
{request.status === "pending" ? (
|
||||
<DecisionButtons
|
||||
requestId={request.id}
|
||||
integrationId={request.integrationId}
|
||||
canInteract={canInteract}
|
||||
alwaysVisible={isTiny}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={request.status} />
|
||||
)}
|
||||
</Group>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Text className="mediaRequests-list-item-media-year" size="xs">
|
||||
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
|
||||
</Text>
|
||||
@@ -164,46 +191,26 @@ const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }:
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap">
|
||||
<Avatar
|
||||
className="mediaRequests-list-item-request-user-avatar"
|
||||
src={request.requestedBy?.avatar}
|
||||
size="xs"
|
||||
/>
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-request-user-name"
|
||||
href={request.requestedBy?.link}
|
||||
c="var(--mantine-color-text)"
|
||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||
fz="xs"
|
||||
lineClamp={1}
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{(request.requestedBy?.displayName ?? "") || "unknown"}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" justify="space-between" className="mediaRequests-list-item-bottom-group">
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||
href={request.href}
|
||||
c="var(--mantine-color-text)"
|
||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={"bold"}
|
||||
title={request.name}
|
||||
lineClamp={1}
|
||||
>
|
||||
{request.name || "unknown"}
|
||||
</Anchor>
|
||||
{request.status === "pending" ? (
|
||||
<DecisionButtons
|
||||
requestId={request.id}
|
||||
integrationId={request.integrationId}
|
||||
canInteract={canInteract}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={request.status} />
|
||||
{!isTiny && (
|
||||
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
|
||||
<Avatar
|
||||
className="mediaRequests-list-item-request-user-avatar"
|
||||
src={request.requestedBy?.avatar}
|
||||
size={18}
|
||||
/>
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-request-user-name"
|
||||
href={request.requestedBy?.link}
|
||||
c="dimmed"
|
||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
|
||||
fz="xs"
|
||||
truncate="end"
|
||||
style={{ minWidth: 0, maxWidth: isAdvanced ? 180 : 100 }}
|
||||
>
|
||||
{(request.requestedBy?.displayName ?? "") || "unknown"}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -217,9 +224,10 @@ interface DecisionButtonsProps {
|
||||
requestId: number;
|
||||
integrationId: string;
|
||||
canInteract: boolean;
|
||||
alwaysVisible: boolean;
|
||||
}
|
||||
|
||||
const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButtonsProps) => {
|
||||
const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible }: DecisionButtonsProps) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const {
|
||||
mutate: mutateRequestAnswer,
|
||||
@@ -239,13 +247,18 @@ const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButt
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className="mediaRequests-list-item-pending-buttons" gap="sm" title={error?.message}>
|
||||
<Group
|
||||
className={`mediaRequests-list-item-pending-buttons ${classes.pendingActions} ${alwaysVisible ? classes.pendingActionsVisible : ""}`}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
title={error?.message}
|
||||
>
|
||||
<Tooltip label={t("pending.approve")}>
|
||||
<ActionIcon
|
||||
className="mediaRequests-list-item-pending-button-approve"
|
||||
variant="light"
|
||||
color="green"
|
||||
size="xs"
|
||||
size="sm"
|
||||
disabled={!canInteract || isPending}
|
||||
aria-label={t("pending.approve")}
|
||||
onClick={() => {
|
||||
@@ -260,7 +273,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButt
|
||||
className="mediaRequests-list-item-pending-button-decline"
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
size="sm"
|
||||
disabled={!canInteract || isPending}
|
||||
aria-label={t("pending.decline")}
|
||||
onClick={() => {
|
||||
|
||||
@@ -5,3 +5,25 @@
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
|
||||
.userCard {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: background-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.userCard {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ export default function MediaServerWidget({
|
||||
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
|
||||
|
||||
const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
|
||||
const showSectionTitles = displayMode === "advanced" || height >= 180;
|
||||
const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
|
||||
const visibleUsers = requestStats.users.slice(
|
||||
0,
|
||||
@@ -119,9 +120,11 @@ export default function MediaServerWidget({
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
{showSectionTitles && (
|
||||
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
)}
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{visibleStats.map((stat) => (
|
||||
<Grid.Col
|
||||
@@ -144,9 +147,11 @@ export default function MediaServerWidget({
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
{showSectionTitles && (
|
||||
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
)}
|
||||
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} w="100%">
|
||||
<Stack gap={4}>
|
||||
{visibleUsers.map((user) => (
|
||||
@@ -159,6 +164,7 @@ export default function MediaServerWidget({
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
classes.userCard,
|
||||
)}
|
||||
key={`${user.integration.id}:${user.id}`}
|
||||
p="xs"
|
||||
@@ -171,7 +177,7 @@ export default function MediaServerWidget({
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
@@ -180,7 +186,7 @@ export default function MediaServerWidget({
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm" truncate="end">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.sessionRow {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
|
||||
|
||||
@@ -87,7 +88,7 @@ export default function MediaTranscodingWidget({
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
const queuePageCount = totalQueuePages ?? 1;
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const isTiny = !isAdvanced && (width < 280 || height < 140);
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
@@ -131,7 +132,9 @@ export default function MediaTranscodingWidget({
|
||||
label: (
|
||||
<Center style={{ gap: 4 }}>
|
||||
<Icon size={12} />
|
||||
{!isTiny && (
|
||||
{isTiny ? (
|
||||
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
|
||||
) : (
|
||||
<Text span size="xs">
|
||||
{t(`tab.${value}`)}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, Divider, Group, Indicator, Popover, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { IconHeartbeat } from "@tabler/icons-react";
|
||||
|
||||
@@ -21,13 +21,15 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
: "green";
|
||||
|
||||
return (
|
||||
<HoverCard position="bottom" width={250} shadow="sm">
|
||||
<HoverCard.Target>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
|
||||
<IconHeartbeat size={16} />
|
||||
</Indicator>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown
|
||||
<Popover position="bottom" width={250} shadow="sm" withArrow>
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="sm" aria-label={t("title")}>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
|
||||
<IconHeartbeat size={16} />
|
||||
</Indicator>
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
style={{
|
||||
backgroundColor: "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))",
|
||||
}}
|
||||
@@ -70,8 +72,8 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { IconCube, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -16,14 +16,25 @@ export default function MinecraftServerStatusWidget({
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"minecraftServerStatus">) {
|
||||
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
|
||||
const { data: result, isPending } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
|
||||
const t = useI18n();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Flex align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("common.action.loading")}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 220 || height < 120);
|
||||
const showServerIcon = isAdvanced || (!isDense && height >= 144);
|
||||
const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
|
||||
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
|
||||
|
||||
@@ -33,11 +44,11 @@ export default function MinecraftServerStatusWidget({
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction="column"
|
||||
p={isAdvanced ? "lg" : "sm"}
|
||||
p={isAdvanced ? "lg" : isDense ? "xs" : "sm"}
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Group gap="xs" wrap="nowrap" align="center" maw="100%">
|
||||
<Tooltip
|
||||
label={
|
||||
data.online
|
||||
@@ -45,8 +56,13 @@ export default function MinecraftServerStatusWidget({
|
||||
: t("widget.minecraftServerStatus.status.offline")
|
||||
}
|
||||
>
|
||||
<Box miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
|
||||
<Box aria-hidden miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }} />
|
||||
</Tooltip>
|
||||
<VisuallyHidden>
|
||||
{data.online
|
||||
? t("widget.minecraftServerStatus.status.online")
|
||||
: t("widget.minecraftServerStatus.status.offline")}
|
||||
</VisuallyHidden>
|
||||
<Text size={isAdvanced ? "xl" : "md"} fw="bold" truncate="end">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -62,6 +78,7 @@ export default function MinecraftServerStatusWidget({
|
||||
{data.online && (
|
||||
<>
|
||||
{!options.isBedrockServer &&
|
||||
showServerIcon &&
|
||||
(data.icon ? (
|
||||
<img
|
||||
style={{ flex: 1, width: iconSize, maxHeight: iconSize, objectFit: "contain" }}
|
||||
@@ -83,7 +100,7 @@ export default function MinecraftServerStatusWidget({
|
||||
<Stack gap={4} w={isAdvanced ? "min(100%, 420px)" : "auto"} align="stretch">
|
||||
<Group gap={5} c="dimmed" align="center" justify="center">
|
||||
<IconUsersGroup size={isAdvanced ? "1.25rem" : "1rem"} />
|
||||
<Text size={isAdvanced ? "lg" : "md"}>
|
||||
<Text size={isAdvanced ? "lg" : isDense ? "sm" : "md"}>
|
||||
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Box, Card, SimpleGrid } from "@mantine/core";
|
||||
import { Box, Card, Center, SimpleGrid, Text } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import objectSupport from "dayjs/plugin/objectSupport";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
@@ -22,10 +23,12 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
integrationIds,
|
||||
displayMode,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<"networkControllerStatus">) {
|
||||
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
|
||||
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const t = useI18n();
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
@@ -33,18 +36,39 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
|
||||
const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
|
||||
const useHorizontalStats = !isAdvanced && height < 150 && width >= 200;
|
||||
|
||||
if (isPending || data.length === 0) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isPending ? t("common.action.loading") : t("widget.networkControllerStatus.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={isAdvanced ? "md" : "sm"} h="100%">
|
||||
<Box p={isAdvanced ? "md" : height < 120 ? "xs" : "sm"} h="100%">
|
||||
<SimpleGrid cols={isAdvanced && width >= 560 ? 2 : 1} h="100%" spacing="sm">
|
||||
{(isAdvanced || options.content === "wifi") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
<WifiVariant countGuests={countWifiGuests} countUsers={countWifiUsers} compact={!isAdvanced} />
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
compact={!isAdvanced}
|
||||
horizontal={useHorizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{(isAdvanced || options.content === "wired") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
<WiredVariant countGuests={countLanGuests} countUsers={countLanUsers} compact={!isAdvanced} />
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
compact={!isAdvanced}
|
||||
horizontal={useHorizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { Group, SimpleGrid, Text } from "@mantine/core";
|
||||
import { IconWifi } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -9,10 +9,12 @@ export const WifiVariant = ({
|
||||
countGuests,
|
||||
countUsers,
|
||||
compact = false,
|
||||
horizontal = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
compact?: boolean;
|
||||
horizontal?: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
@@ -23,10 +25,10 @@ export const WifiVariant = ({
|
||||
{t("variants.wifi.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={compact ? "sm" : "lg"}>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { Group, SimpleGrid, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -9,10 +9,12 @@ export const WiredVariant = ({
|
||||
countGuests,
|
||||
countUsers,
|
||||
compact = false,
|
||||
horizontal = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
compact?: boolean;
|
||||
horizontal?: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
@@ -23,10 +25,10 @@ export const WiredVariant = ({
|
||||
{t("variants.wired.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={compact ? "sm" : "lg"}>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Card, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
@@ -19,19 +19,31 @@ dayjs.extend(duration);
|
||||
export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
|
||||
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && height < 160;
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p={isAdvanced ? "md" : "sm"}>
|
||||
<ScrollArea h="100%" p={isAdvanced ? "md" : isDense ? "xs" : "sm"}>
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
|
||||
@@ -46,9 +58,9 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
<List spacing="xs" center>
|
||||
<List.Item icon={<StatusIcon status={summary.wanStatus} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.www.status} />}>
|
||||
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
|
||||
<List.Item icon={<StatusIcon status={summary.wanStatus} size={isDense ? 16 : 20} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.www.status} size={isDense ? 16 : 20} />}>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
@@ -56,15 +68,15 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.wifi.status} />}>
|
||||
<List.Item icon={<StatusIcon status={summary.wifi.status} size={isDense ? 16 : 20} />}>
|
||||
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
</List.Item>
|
||||
{isAdvanced && (
|
||||
<List.Item icon={<StatusIcon status={summary.lan.status} />}>
|
||||
<List.Item icon={<StatusIcon status={summary.lan.status} size={20} />}>
|
||||
LAN · {summary.lan.users + summary.lan.guests}
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item icon={<StatusIcon status={summary.vpn.status} />}>
|
||||
<List.Item icon={<StatusIcon status={summary.vpn.status} size={isDense ? 16 : 20} />}>
|
||||
<Text>
|
||||
VPN
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
@@ -81,11 +93,13 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => {
|
||||
const StatusIcon = ({ status, size }: { status?: "enabled" | "disabled"; size: number }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled size={20} color={mantineTheme.colors.green[6]} />;
|
||||
return <IconCircleCheckFilled aria-label={status} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
if (status === "disabled") return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
|
||||
return <IconCircleXFilled size={20} color={mantineTheme.colors.gray[6]} />;
|
||||
if (status === "disabled") {
|
||||
return <IconCircleXFilled aria-label={status} size={size} color={mantineTheme.colors.red[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled aria-label="unknown" size={size} color={mantineTheme.colors.gray[6]} />;
|
||||
};
|
||||
@@ -70,3 +70,31 @@
|
||||
.ProseMirror {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.homarr-notebook-action {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.homarr-notebook:hover .homarr-notebook-action,
|
||||
.homarr-notebook:focus-within .homarr-notebook-action,
|
||||
.homarr-notebook-action[data-visible="true"] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.homarr-notebook-action {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.homarr-notebook-action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ export function Notebook({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box h="100%" onDoubleClick={handleDoubleClick}>
|
||||
<Box className="homarr-notebook" h="100%" onDoubleClick={handleDoubleClick}>
|
||||
<RichTextEditor
|
||||
p={0}
|
||||
mt={0}
|
||||
@@ -528,6 +528,8 @@ export function Notebook({
|
||||
{canChange && (
|
||||
<>
|
||||
<ActionIcon
|
||||
className="homarr-notebook-action"
|
||||
data-visible={isEditing || displayMode === "advanced" || undefined}
|
||||
title={isEditing ? t("common.action.save") : t("common.action.edit")}
|
||||
aria-label={isEditing ? t("common.action.save") : t("common.action.edit")}
|
||||
style={{
|
||||
@@ -547,6 +549,8 @@ export function Notebook({
|
||||
</ActionIcon>
|
||||
{isEditing && (
|
||||
<ActionIcon
|
||||
className="homarr-notebook-action"
|
||||
data-visible
|
||||
title={t("common.action.cancel")}
|
||||
aria-label={t("common.action.cancel")}
|
||||
style={{
|
||||
|
||||
@@ -15,9 +15,10 @@ export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
const { data: notificationIntegrations = [], isPending } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
});
|
||||
@@ -43,6 +44,8 @@ export default function NotificationsWidget({
|
||||
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
|
||||
);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 280 || height < 180);
|
||||
const bodyLineClamp = isAdvanced ? 12 : height < 112 ? 1 : isDense ? 2 : 4;
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
|
||||
return (
|
||||
@@ -59,7 +62,13 @@ export default function NotificationsWidget({
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{sortedNotifications.length > 0 ? (
|
||||
{isPending ? (
|
||||
<Flex justify="center" align="center" mih={96} p="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("common.action.loading")}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : sortedNotifications.length > 0 ? (
|
||||
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
|
||||
{sortedNotifications.map((notification) => (
|
||||
<Card
|
||||
@@ -70,32 +79,45 @@ export default function NotificationsWidget({
|
||||
rel={notification.href ? "noopener noreferrer" : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isAdvanced ? "md" : "xs"}
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
|
||||
bg={isAdvanced ? undefined : "transparent"}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
borderBottom: isAdvanced
|
||||
? undefined
|
||||
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
|
||||
}}
|
||||
>
|
||||
<Flex gap="sm" align="flex-start" w="100%">
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size="sm"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
<Flex gap={isAdvanced ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
|
||||
{notification.title && (
|
||||
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text c="dimmed" size="sm" lineClamp={isAdvanced ? 12 : 4} style={{ whiteSpace: "pre-line" }}>
|
||||
<Text
|
||||
c="dimmed"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
lineClamp={bodyLineClamp}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
dense={isDense}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -103,22 +125,24 @@ export default function NotificationsWidget({
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.notifications.noItems")}
|
||||
</Text>
|
||||
<Flex justify="center" align="center" mih={96} p="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("widget.notifications.noItems")}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => {
|
||||
const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; dense: boolean }) => {
|
||||
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
|
||||
|
||||
return (
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
|
||||
<Text size="sm" c="dimmed">
|
||||
<IconClock aria-hidden size={dense ? 12 : "1rem"} color="var(--mantine-color-dimmed)" />
|
||||
<Text size={dense ? "xs" : "sm"} c="dimmed">
|
||||
{timeAgo}
|
||||
</Text>
|
||||
{source && <Text c="dimmed">•</Text>}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Fragment } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { RingProgress, Text } from "@mantine/core";
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -78,10 +78,20 @@ export default function PaperlessNgxWidget({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"paperlessNgx">) {
|
||||
const t = useScopedI18n("widget.paperlessNgx");
|
||||
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
|
||||
const tCommon = useScopedI18n("common");
|
||||
const { data: stats, isPending } = clientApi.widget.paperlessNgx.getStats.useQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{tCommon("action.loading")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
|
||||
|
||||
@@ -7,20 +7,41 @@
|
||||
}
|
||||
|
||||
.releasesRepositoryHeader {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.active,
|
||||
&:focus-visible,
|
||||
&:has(~ .releasesRepositoryDetails:hover) {
|
||||
background-color: var(--mantine-color-secondaryColor-light);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.releasesRepositoryDetails {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.releasesRepositoryExpanded {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Divider, Group, ScrollArea, SimpleGrid, Stack, Text, Title, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import {
|
||||
IconArchive,
|
||||
@@ -204,105 +215,111 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Group
|
||||
<UnstyledButton
|
||||
className={combineClasses("releases-repository-header", classes.releasesRepositoryHeader, {
|
||||
[classes.active ?? ""]: isActive,
|
||||
})}
|
||||
p="xs"
|
||||
onClick={() => toggleExpandedDisplay(repository)}
|
||||
aria-expanded={isActive}
|
||||
>
|
||||
<MaskedOrNormalImage
|
||||
className="releases-repository-header-icon"
|
||||
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
<Group p="xs">
|
||||
<MaskedOrNormalImage
|
||||
className="releases-repository-header-icon"
|
||||
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group
|
||||
className="releases-repository-header-nameVersion-wrapper"
|
||||
gap={5}
|
||||
justify="space-between"
|
||||
miw={0}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{!options.showOnlyIcon && (
|
||||
<Text className="releases-repository-header-name" size="xs">
|
||||
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
|
||||
{repository.name || repository.identifier}
|
||||
</Text>
|
||||
)}
|
||||
<Group
|
||||
className="releases-repository-header-nameVersion-wrapper"
|
||||
gap={5}
|
||||
justify="space-between"
|
||||
miw={0}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{!options.showOnlyIcon && (
|
||||
<Text className="releases-repository-header-name" size="xs">
|
||||
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
|
||||
{repository.name || repository.identifier}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
className="releases-repository-header-version-tooltip"
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
label={repository.latestRelease}
|
||||
events={{ hover: repository.latestRelease !== undefined, focus: false, touch: false }}
|
||||
<Tooltip
|
||||
className="releases-repository-header-version-tooltip"
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
label={repository.latestRelease}
|
||||
events={{ hover: repository.latestRelease !== undefined, focus: true, touch: false }}
|
||||
>
|
||||
<Text
|
||||
className="releases-repository-header-version"
|
||||
size="xs"
|
||||
fw={700}
|
||||
truncate="end"
|
||||
c={hasError ? "red" : "text"}
|
||||
style={{ flexShrink: 1 }}
|
||||
>
|
||||
{hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
className="releases-repository-header-releaseDate-wrapper"
|
||||
gap={5}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
<Text
|
||||
className="releases-repository-header-version"
|
||||
className="releases-repository-header-releaseDate"
|
||||
size="xs"
|
||||
fw={700}
|
||||
truncate="end"
|
||||
c={hasError ? "red" : "text"}
|
||||
style={{ flexShrink: 1 }}
|
||||
c={
|
||||
repository.viewed
|
||||
? "green"
|
||||
: repository.isNewRelease
|
||||
? "primaryColor"
|
||||
: repository.isStaleRelease
|
||||
? "secondaryColor"
|
||||
: "dimmed"
|
||||
}
|
||||
>
|
||||
{hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
|
||||
{repository.latestReleaseAt &&
|
||||
!hasError &&
|
||||
formatter.relativeTime(repository.latestReleaseAt, {
|
||||
now,
|
||||
style: "long",
|
||||
})}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group className="releases-repository-header-releaseDate-wrapper" gap={5} style={{ flex: "0 0 auto" }}>
|
||||
<Text
|
||||
className="releases-repository-header-releaseDate"
|
||||
size="xs"
|
||||
c={
|
||||
repository.viewed
|
||||
? "green"
|
||||
: repository.isNewRelease
|
||||
? "primaryColor"
|
||||
: repository.isStaleRelease
|
||||
? "secondaryColor"
|
||||
: "dimmed"
|
||||
}
|
||||
>
|
||||
{repository.latestReleaseAt &&
|
||||
!hasError &&
|
||||
formatter.relativeTime(repository.latestReleaseAt, {
|
||||
now,
|
||||
style: "long",
|
||||
})}
|
||||
</Text>
|
||||
{hasError ? (
|
||||
<IconTriangleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
|
||||
size={10}
|
||||
color="var(--mantine-color-red-filled)"
|
||||
/>
|
||||
) : repository.viewed ? (
|
||||
<IconCheck
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
|
||||
size={10}
|
||||
color="green"
|
||||
/>
|
||||
) : (
|
||||
(repository.isNewRelease || repository.isStaleRelease) && (
|
||||
<IconCircleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
|
||||
{hasError ? (
|
||||
<IconTriangleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
|
||||
size={10}
|
||||
color={
|
||||
repository.isNewRelease
|
||||
? "var(--mantine-color-primaryColor-filled)"
|
||||
: "var(--mantine-color-secondaryColor-filled)"
|
||||
}
|
||||
color="var(--mantine-color-red-filled)"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
) : repository.viewed ? (
|
||||
<IconCheck
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
|
||||
size={10}
|
||||
color="green"
|
||||
/>
|
||||
) : (
|
||||
(repository.isNewRelease || repository.isStaleRelease) && (
|
||||
<IconCircleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
|
||||
size={10}
|
||||
color={
|
||||
repository.isNewRelease
|
||||
? "var(--mantine-color-primaryColor-filled)"
|
||||
: "var(--mantine-color-secondaryColor-filled)"
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{(options.showDetails || isAdvanced) && (
|
||||
<DetailsDisplay repository={repository} toggleExpandedDisplay={toggleExpandedDisplay} />
|
||||
)}
|
||||
@@ -334,198 +351,203 @@ const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayPro
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="releases-repository-details-divider" onClick={() => toggleExpandedDisplay(repository)} />
|
||||
<Group
|
||||
<Divider className="releases-repository-details-divider" />
|
||||
<UnstyledButton
|
||||
className={combineClasses("releases-repository-details", classes.releasesRepositoryDetails)}
|
||||
justify="space-between"
|
||||
p={5}
|
||||
onClick={() => toggleExpandedDisplay(repository)}
|
||||
>
|
||||
<Group className="releases-repository-details-icon-wrapper">
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon-tooltip",
|
||||
"releases-repository-details-icon-preRelease-tooltip",
|
||||
)}
|
||||
label={t("pre-release")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconProgressCheck
|
||||
<Group justify="space-between" p={5}>
|
||||
<Group className="releases-repository-details-icon-wrapper">
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-preRelease",
|
||||
"releases-repository-details-icon-tooltip",
|
||||
"releases-repository-details-icon-preRelease-tooltip",
|
||||
)}
|
||||
size={13}
|
||||
color={
|
||||
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon-tooltip",
|
||||
"releases-repository-details-icon-archived-tooltip",
|
||||
)}
|
||||
label={t("archived")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconArchive
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-archived",
|
||||
)}
|
||||
size={13}
|
||||
color={repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon-tooltip",
|
||||
"releases-repository-details-icon-forked-tooltip",
|
||||
)}
|
||||
label={t("forked")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconGitFork
|
||||
className={combineClasses("releases-repository-details-icon", "releases-repository-details-icon-forked")}
|
||||
size={13}
|
||||
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group className="releases-repository-details-stats">
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-tooltip",
|
||||
"releases-repository-details-stats-stars-tooltip",
|
||||
)}
|
||||
label={t("starsCount")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<Group
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-wrapper",
|
||||
"releases-repository-details-stats-stars-wrapper",
|
||||
)}
|
||||
gap={5}
|
||||
label={t("pre-release")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconStar
|
||||
<IconProgressCheck
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-stars-icon",
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-preRelease",
|
||||
)}
|
||||
size={12}
|
||||
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
size={13}
|
||||
color={
|
||||
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-text",
|
||||
"releases-repository-details-stats-stars-text",
|
||||
)}
|
||||
size="xs"
|
||||
c={!repository.starsCount ? "dimmed" : ""}
|
||||
>
|
||||
{!repository.starsCount
|
||||
? "-"
|
||||
: formatter.number(repository.starsCount, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-tooltip",
|
||||
"releases-repository-details-stats-forks-tooltip",
|
||||
)}
|
||||
label={t("forksCount")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<Group
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-wrapper",
|
||||
"releases-repository-details-stats-forks-wrapper",
|
||||
"releases-repository-details-icon-tooltip",
|
||||
"releases-repository-details-icon-archived-tooltip",
|
||||
)}
|
||||
gap={5}
|
||||
label={t("archived")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconArchive
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-archived",
|
||||
)}
|
||||
size={13}
|
||||
color={
|
||||
repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-icon-tooltip",
|
||||
"releases-repository-details-icon-forked-tooltip",
|
||||
)}
|
||||
label={t("forked")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconGitFork
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-forks-icon",
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-forked",
|
||||
)}
|
||||
size={12}
|
||||
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
size={13}
|
||||
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
<Text
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-text",
|
||||
"releases-repository-details-stats-forks-text",
|
||||
)}
|
||||
size="xs"
|
||||
c={!repository.forksCount ? "dimmed" : ""}
|
||||
>
|
||||
{!repository.forksCount
|
||||
? "-"
|
||||
: formatter.number(repository.forksCount, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-tooltip",
|
||||
"releases-repository-details-stats-issues-tooltip",
|
||||
)}
|
||||
label={t("issuesCount")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<Group
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group className="releases-repository-details-stats">
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-wrapper",
|
||||
"releases-repository-details-stats-issues-wrapper",
|
||||
"releases-repository-details-stats-tooltip",
|
||||
"releases-repository-details-stats-stars-tooltip",
|
||||
)}
|
||||
gap={5}
|
||||
label={t("starsCount")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<IconCircleDot
|
||||
<Group
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-issues-icon",
|
||||
"releases-repository-details-stats-wrapper",
|
||||
"releases-repository-details-stats-stars-wrapper",
|
||||
)}
|
||||
size={12}
|
||||
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-text",
|
||||
"releases-repository-details-stats-issues-text",
|
||||
)}
|
||||
size="xs"
|
||||
c={!repository.openIssues ? "dimmed" : ""}
|
||||
gap={5}
|
||||
>
|
||||
{!repository.openIssues
|
||||
? "-"
|
||||
: formatter.number(repository.openIssues, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
<IconStar
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-stars-icon",
|
||||
)}
|
||||
size={12}
|
||||
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-text",
|
||||
"releases-repository-details-stats-stars-text",
|
||||
)}
|
||||
size="xs"
|
||||
c={!repository.starsCount ? "dimmed" : ""}
|
||||
>
|
||||
{!repository.starsCount
|
||||
? "-"
|
||||
: formatter.number(repository.starsCount, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-tooltip",
|
||||
"releases-repository-details-stats-forks-tooltip",
|
||||
)}
|
||||
label={t("forksCount")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<Group
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-wrapper",
|
||||
"releases-repository-details-stats-forks-wrapper",
|
||||
)}
|
||||
gap={5}
|
||||
>
|
||||
<IconGitFork
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-forks-icon",
|
||||
)}
|
||||
size={12}
|
||||
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-text",
|
||||
"releases-repository-details-stats-forks-text",
|
||||
)}
|
||||
size="xs"
|
||||
c={!repository.forksCount ? "dimmed" : ""}
|
||||
>
|
||||
{!repository.forksCount
|
||||
? "-"
|
||||
: formatter.number(repository.forksCount, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-tooltip",
|
||||
"releases-repository-details-stats-issues-tooltip",
|
||||
)}
|
||||
label={t("issuesCount")}
|
||||
withArrow
|
||||
arrowSize={5}
|
||||
>
|
||||
<Group
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-wrapper",
|
||||
"releases-repository-details-stats-issues-wrapper",
|
||||
)}
|
||||
gap={5}
|
||||
>
|
||||
<IconCircleDot
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-issues-icon",
|
||||
)}
|
||||
size={12}
|
||||
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
className={combineClasses(
|
||||
"releases-repository-details-stats-text",
|
||||
"releases-repository-details-stats-issues-text",
|
||||
)}
|
||||
size="xs"
|
||||
c={!repository.openIssues ? "dimmed" : ""}
|
||||
>
|
||||
{!repository.openIssues
|
||||
? "-"
|
||||
: formatter.number(repository.openIssues, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
.backgroundImage {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(5px);
|
||||
transform: scaleX(-1.05) scaleZ(-1.05);
|
||||
opacity: 0.25;
|
||||
top: 0;
|
||||
left: 0;
|
||||
object-fit: cover;
|
||||
.entry {
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: background-color 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entry {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) {
|
||||
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
|
||||
const {
|
||||
entries: feedEntries,
|
||||
failedFeedCount,
|
||||
@@ -54,11 +54,13 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
|
||||
const languageDir = options.enableRtl ? "RTL" : "LTR";
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isNarrow = !isAdvanced && width < 128 * 3;
|
||||
const isDense = !isAdvanced && (width < 420 || height < 180);
|
||||
const isTiny = !isAdvanced && (width < 260 || height < 110);
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
const descriptionLines = isAdvanced ? Math.max(options.textLinesClamp, 8) : isDense ? 1 : options.textLinesClamp;
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : isTiny ? 4 : "xs"}>
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
|
||||
<output>{warning}</output>
|
||||
@@ -68,25 +70,24 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
|
||||
{feedEntries.map((feedEntry) => (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
className={classes.entry}
|
||||
component={"a"}
|
||||
href={getSafeExternalUrl(feedEntry.link, feedEntry.feedUrl)}
|
||||
radius={board.itemRadius}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
w="100%"
|
||||
p={isAdvanced ? "md" : "xs"}
|
||||
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
|
||||
title={feedEntry.title}
|
||||
>
|
||||
{feedEntry.enclosure !== undefined && (
|
||||
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
|
||||
)}
|
||||
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
|
||||
<Image
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt={feedEntry.title}
|
||||
w={isAdvanced ? 180 : 112}
|
||||
h={isAdvanced ? 120 : 112}
|
||||
alt=""
|
||||
w={isAdvanced ? 180 : isDense ? 64 : 96}
|
||||
h={isAdvanced ? 120 : isDense ? 64 : 96}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
@@ -96,19 +97,14 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
|
||||
<Text dir={languageDir} fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && (
|
||||
<Text
|
||||
dir={languageDir}
|
||||
c="dimmed"
|
||||
size="sm"
|
||||
lineClamp={isAdvanced ? Math.max(options.textLinesClamp, 8) : options.textLinesClamp}
|
||||
>
|
||||
{!options.hideDescription && feedEntry.description && !isTiny && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<InfoDisplay
|
||||
source={isAdvanced ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
source={isAdvanced || !isDense ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -83,12 +83,18 @@ export default function SmartHomeEntityStateWidget({
|
||||
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
|
||||
{ key: "icon" as const, value: entity?.attributes.icon },
|
||||
].filter(({ value }) => typeof value === "string" && value.length > 0);
|
||||
const displayName =
|
||||
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
|
||||
? entity.attributes.friendly_name
|
||||
: options.displayName;
|
||||
const state = entity?.state ?? "—";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
mod={{ "entity-state": entity?.state, "entity-id": options.entityId }}
|
||||
onClick={handleClick}
|
||||
disabled={!isActionable}
|
||||
aria-label={`${displayName}: ${state}${attribute}`}
|
||||
aria-description={error?.message}
|
||||
w="100%"
|
||||
h="100%"
|
||||
@@ -101,15 +107,13 @@ export default function SmartHomeEntityStateWidget({
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%">
|
||||
<Text ta="center" fw="bold" size={isTiny ? "sm" : "lg"}>
|
||||
{displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
|
||||
? entity.attributes.friendly_name
|
||||
: options.displayName}
|
||||
</Text>
|
||||
<Text ta="center" size={isTiny ? "xs" : "lg"}>
|
||||
{entity?.state ?? "—"}
|
||||
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
|
||||
{state}
|
||||
{attribute}
|
||||
</Text>
|
||||
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
|
||||
{displayName}
|
||||
</Text>
|
||||
{displayMode === "advanced" && entity && (
|
||||
<>
|
||||
<Group justify="center" gap={4}>
|
||||
@@ -131,7 +135,7 @@ export default function SmartHomeEntityStateWidget({
|
||||
)}
|
||||
{error && (
|
||||
<Tooltip label={error.message} multiline>
|
||||
<Text size="xs" c="red" lineClamp={2}>
|
||||
<Text size="xs" c="red" lineClamp={2} tabIndex={0}>
|
||||
{t("widget.smartHome-entityState.error.toggleFailed")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
|
||||
return (
|
||||
<Box pos="relative" w="100%" h="100%">
|
||||
<VisuallyHidden role="status">
|
||||
<VisuallyHidden component="output">
|
||||
{isPending
|
||||
? t("widget.smartHome-executeAutomation.status.running")
|
||||
: isShowSuccess
|
||||
@@ -100,6 +100,7 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
disabled={!integrationId || !canInteract || isPending}
|
||||
aria-label={t("widget.smartHome-executeAutomation.spotlightAction.run", { name: options.displayName })}
|
||||
style={{
|
||||
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
@@ -130,9 +131,11 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
</Overlay>
|
||||
)}
|
||||
<Center w="100%" h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<IconAutomation size={isTiny ? 16 : undefined} />
|
||||
<Text ta="center" fw="bold" fz={isTiny ? "xs" : undefined}>
|
||||
<Stack align="center" gap={isTiny ? 6 : "md"} p="xs" maw="100%">
|
||||
<ThemeIcon variant="light" size={isTiny ? "md" : "xl"} radius="xl">
|
||||
<IconAutomation size={isTiny ? 14 : 24} />
|
||||
</ThemeIcon>
|
||||
<Text ta="center" fw={600} fz={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
|
||||
{options.displayName}
|
||||
</Text>
|
||||
{displayMode === "advanced" && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { combineSpeedtestDashboards } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
import { RecentResultsSection } from "./recent-results";
|
||||
|
||||
@@ -48,6 +48,11 @@ export default function SpeedtestTrackerWidget({
|
||||
const averages = options.showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
|
||||
);
|
||||
const compactSections = getCompactSections(height, {
|
||||
latest: Boolean(latest),
|
||||
chart: hasChart,
|
||||
averages: Boolean(averages),
|
||||
});
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
return (
|
||||
@@ -85,13 +90,13 @@ export default function SpeedtestTrackerWidget({
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||
{hasStatSection && (
|
||||
{(compactSections.latest || compactSections.averages) && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{averages && height >= 170 && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{hasChart && (
|
||||
{compactSections.chart && (
|
||||
<div style={{ flex: 2, minHeight: 0 }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
|
||||
|
||||
import { combineSpeedtestDashboards, mergeStats } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
|
||||
|
||||
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
|
||||
ping: { avg: average, min: average, max: average },
|
||||
@@ -26,3 +26,16 @@ describe("speedtest dashboard aggregation", () => {
|
||||
expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompactSections", () => {
|
||||
const available = { latest: true, chart: true, averages: true };
|
||||
|
||||
it("prioritizes the latest result in a short widget", () => {
|
||||
expect(getCompactSections(180, available)).toEqual({ latest: true, chart: false, averages: false });
|
||||
});
|
||||
|
||||
it("reveals history before averages as height grows", () => {
|
||||
expect(getCompactSections(300, available)).toEqual({ latest: true, chart: true, averages: false });
|
||||
expect(getCompactSections(400, available)).toEqual({ latest: true, chart: true, averages: true });
|
||||
});
|
||||
});
|
||||
@@ -75,3 +75,21 @@ export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): strin
|
||||
if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits);
|
||||
return formatBitsPerSec(band.avg * 8);
|
||||
};
|
||||
|
||||
type CompactSection = "latest" | "chart" | "averages";
|
||||
|
||||
export const getCompactSections = (
|
||||
height: number,
|
||||
available: Record<CompactSection, boolean>,
|
||||
): Record<CompactSection, boolean> => {
|
||||
const budget = height < 240 ? 1 : height < 360 ? 2 : 3;
|
||||
const visible = new Set(
|
||||
(["latest", "chart", "averages"] as const).filter((section) => available[section]).slice(0, budget),
|
||||
);
|
||||
|
||||
return {
|
||||
latest: visible.has("latest"),
|
||||
chart: visible.has("chart"),
|
||||
averages: visible.has("averages"),
|
||||
};
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
|
||||
<Stack gap={6} h="100%">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<SectionLabel>{t("latestResult")}</SectionLabel>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
|
||||
{timestamp}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Text } from "@mantine/core";
|
||||
|
||||
export function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Flex, Text } from "@mantine/core";
|
||||
import { Box, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -21,34 +21,37 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
const hideLabel = height > 0 && height <= 38;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
bg={`var(--mantine-color-${color}-light)`}
|
||||
h="100%"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Flex
|
||||
<Tooltip label={`${label}: ${value}`} withArrow>
|
||||
<Box
|
||||
ref={ref}
|
||||
p={compact ? "xs" : "sm"}
|
||||
bg={`var(--mantine-color-${color}-light)`}
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction={isWide ? "row" : "column"}
|
||||
gap={isWide ? 8 : 4}
|
||||
style={{ flex: 1, borderRadius: `var(--mantine-radius-${board.itemRadius})`, minWidth: 0 }}
|
||||
>
|
||||
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
|
||||
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
{!hideLabel && (
|
||||
<Text size="xs" c="dimmed" ta="center" lh={1.3}>
|
||||
{label}
|
||||
<Flex
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction={isWide ? "row" : "column"}
|
||||
gap={isWide ? 8 : 4}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{!hideLabel && (
|
||||
<Text size="xs" c="dimmed" ta="center" lh={1.3} truncate w="100%">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
{hideLabel && <VisuallyHidden>{label}</VisuallyHidden>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getStockSummary } from "./component";
|
||||
import { getStockLayout, getStockSummary } from "./component";
|
||||
|
||||
describe("stock summary", () => {
|
||||
it("returns no summary for an empty series", () => {
|
||||
@@ -16,3 +16,38 @@ describe("stock summary", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stock layout", () => {
|
||||
it("keeps compact widgets focused on symbol and price", () => {
|
||||
expect(getStockLayout(320, 100, false)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: false,
|
||||
showRange: false,
|
||||
priceOrder: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals secondary data only when both axes have room", () => {
|
||||
expect(getStockLayout(320, 160, false)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
});
|
||||
expect(getStockLayout(320, 220, false)).toMatchObject({
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
graphHeight: "68%",
|
||||
});
|
||||
});
|
||||
|
||||
it("always exposes the complete advanced summary", () => {
|
||||
expect(getStockLayout(120, 80, true)).toEqual({
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
graphHeight: "75%",
|
||||
priceOrder: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,34 @@ export interface StockSummary {
|
||||
graphValues: number[];
|
||||
}
|
||||
|
||||
export interface StockLayout {
|
||||
showName: boolean;
|
||||
showChange: boolean;
|
||||
showRange: boolean;
|
||||
graphHeight: "48%" | "68%" | "75%";
|
||||
priceOrder: 1 | 2;
|
||||
}
|
||||
|
||||
export function getStockLayout(width: number, height: number, isAdvanced: boolean): StockLayout {
|
||||
if (isAdvanced) {
|
||||
return {
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
graphHeight: "75%",
|
||||
priceOrder: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
showName: width >= 300 && height >= 190,
|
||||
showChange: width >= 260 && height >= 130,
|
||||
showRange: width >= 220 && height >= 130,
|
||||
graphHeight: height >= 220 ? "68%" : "48%",
|
||||
priceOrder: width >= 280 && height >= 120 ? 1 : 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStockSummary(priceHistory: number[], previousClose: number): StockSummary | null {
|
||||
const currentPrice = priceHistory.at(-1);
|
||||
if (currentPrice === undefined) return null;
|
||||
@@ -57,6 +85,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
|
||||
const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage);
|
||||
const stockGraphValues = summary.graphValues;
|
||||
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
|
||||
const layout = getStockLayout(width, height, displayMode === "advanced");
|
||||
|
||||
return (
|
||||
<Flex h="100%" w="100%">
|
||||
@@ -64,7 +93,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
|
||||
pos="absolute"
|
||||
bottom={10}
|
||||
w="100%"
|
||||
h={displayMode === "advanced" || height > 280 ? "75%" : "50%"}
|
||||
h={layout.graphHeight}
|
||||
data={stockGraphValues}
|
||||
curveType="linear"
|
||||
color={trendColor}
|
||||
@@ -81,18 +110,18 @@ export default function StockPriceWidget({ options, width, height, displayMode }
|
||||
)}
|
||||
{data.symbol}
|
||||
</Text>
|
||||
{(displayMode === "advanced" || (width > 280 && height > 280)) && (
|
||||
{layout.showName && (
|
||||
<Text size="md" lh="1">
|
||||
{data.shortName}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Title pos="absolute" bottom={10} right={10} order={width > 280 ? 1 : 2} fw={700}>
|
||||
<Title pos="absolute" bottom={10} right={10} order={layout.priceOrder} fw={700}>
|
||||
{new Intl.NumberFormat().format(round(summary.currentPrice))}
|
||||
</Title>
|
||||
|
||||
{(displayMode === "advanced" || width > 280) && (
|
||||
{layout.showChange && (
|
||||
<Text pos="absolute" top={10} right={10} size="xl" fw={700}>
|
||||
{new Intl.NumberFormat().format(stockValuesChange)}
|
||||
{stockValuesChangePercentage === null
|
||||
@@ -101,7 +130,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(displayMode === "advanced" || width > 280) && (
|
||||
{layout.showRange && (
|
||||
<Text pos="absolute" bottom={10} left={10} fw={700}>
|
||||
{t(`option.timeRange.option.${options.timeRange}.label`)}
|
||||
</Text>
|
||||
|
||||
@@ -58,6 +58,7 @@ interface SystemDiskCardProps {
|
||||
integrationName?: string;
|
||||
secondaryText?: string;
|
||||
isAdvanced: boolean;
|
||||
showTemperature: boolean;
|
||||
}
|
||||
|
||||
const SystemDiskCard = ({
|
||||
@@ -70,6 +71,7 @@ const SystemDiskCard = ({
|
||||
integrationName,
|
||||
secondaryText,
|
||||
isAdvanced,
|
||||
showTemperature,
|
||||
}: SystemDiskCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const scheme = useMantineColorScheme();
|
||||
@@ -95,11 +97,18 @@ const SystemDiskCard = ({
|
||||
}, [displayText, healthy]);
|
||||
|
||||
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
|
||||
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
|
||||
const tooltipLabel = [
|
||||
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
|
||||
hasHiddenTemperature ? `${temperature}°C` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={healthy ? displayText : `${displayText} (${unhealthyLabel})`}
|
||||
disabled={valueFits || isAdvanced}
|
||||
label={tooltipLabel}
|
||||
disabled={(valueFits && !hasHiddenTemperature) || isAdvanced}
|
||||
position="top"
|
||||
withinPortal
|
||||
>
|
||||
@@ -109,9 +118,9 @@ const SystemDiskCard = ({
|
||||
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
|
||||
style={{ overflow: "hidden", position: "relative" }}
|
||||
>
|
||||
<Group justify="space-between" style={{ zIndex: 1 }}>
|
||||
<div>
|
||||
<Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
|
||||
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{deviceName}
|
||||
</Text>
|
||||
{integrationName && (
|
||||
@@ -119,22 +128,23 @@ const SystemDiskCard = ({
|
||||
{integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<p ref={valueRef} style={{ margin: 0, visibility: valueFits ? "visible" : "hidden" }}>
|
||||
<Text ref={valueRef} size="sm" style={{ visibility: valueFits ? "visible" : "hidden" }}>
|
||||
<span>{displayText}</span>
|
||||
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
|
||||
</p>
|
||||
</Text>
|
||||
{isAdvanced && secondaryText && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{secondaryText}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{temperature !== null && temperature !== undefined ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{showTemperature && temperature !== null && temperature !== undefined ? (
|
||||
<Text size="sm">{temperature}°C</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
<Box
|
||||
bg={healthy ? "green" : "red"}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -143,6 +153,7 @@ const SystemDiskCard = ({
|
||||
height: "100%",
|
||||
zIndex: 0,
|
||||
display: showBackgroundBar ? "block" : "none",
|
||||
backgroundColor: healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)",
|
||||
}}
|
||||
></Box>
|
||||
</Card>
|
||||
@@ -154,6 +165,7 @@ export default function SystemResources({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode: surfaceMode,
|
||||
}: WidgetComponentProps<"systemDisks">) {
|
||||
const queryInput = { integrationIds };
|
||||
@@ -182,7 +194,9 @@ export default function SystemResources({
|
||||
if (disks.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
const isAdvanced = surfaceMode === "advanced";
|
||||
const columns = isAdvanced ? Math.max(1, Math.min(disks.length, Math.floor(width / 320))) : 1;
|
||||
const minimumCardWidth = isAdvanced ? 320 : 260;
|
||||
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
|
||||
const cellWidth = width / columns;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
@@ -199,6 +213,7 @@ export default function SystemResources({
|
||||
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
|
||||
secondaryText={getAbsoluteText(item)}
|
||||
isAdvanced={isAdvanced}
|
||||
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -83,6 +83,13 @@ export const CommonChart = ({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
|
||||
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
|
||||
<Text size="xs" fw={600}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
@@ -92,6 +99,11 @@ export const CommonChart = ({
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{lastValue && (
|
||||
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
|
||||
@@ -22,7 +22,7 @@ export const SystemResourceMemoryChart = ({
|
||||
const t = useScopedI18n("widget.systemResources.card");
|
||||
|
||||
const percentageUsed =
|
||||
memoryUsageOverTime.length > 0
|
||||
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
|
||||
: undefined;
|
||||
@@ -45,7 +45,8 @@ export const SystemResourceMemoryChart = ({
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
|
||||
{memory.used} / {memory.available} (
|
||||
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const NetworkTrafficChart = ({
|
||||
|
||||
const max = Math.max(...usageOverTime);
|
||||
const upperBound = max + max * 0.2;
|
||||
const latest = usageOverTime.at(-1) ?? 0;
|
||||
|
||||
return (
|
||||
<CommonChart
|
||||
@@ -32,7 +33,7 @@ export const NetworkTrafficChart = ({
|
||||
title={isUp ? t("up") : t("down")}
|
||||
icon={isUp ? IconArrowUp : IconArrowDown}
|
||||
yAxisProps={{ domain: [0, upperBound] }}
|
||||
lastValue={formatByteRate(Math.round(max))}
|
||||
lastValue={formatByteRate(Math.round(latest))}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
tooltipProps={{
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
|
||||
import { SystemResourceCPUChart } from "./chart/cpu-chart";
|
||||
import { SystemResourceGPUChart } from "./chart/gpu-chart";
|
||||
@@ -14,6 +19,9 @@ import { NetworkTrafficChart } from "./chart/network-traffic";
|
||||
|
||||
const COMPACT_HISTORY_SIZE = 15;
|
||||
const ADVANCED_HISTORY_SIZE = 60;
|
||||
const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const;
|
||||
|
||||
type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number];
|
||||
|
||||
type ChartItem = {
|
||||
cpu: number;
|
||||
@@ -40,19 +48,59 @@ export const toChartItem = (healthInfo: {
|
||||
export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
|
||||
[...history, item].slice(-limit);
|
||||
|
||||
export const getNetworkHistory = (items: readonly ChartItem[]) =>
|
||||
items.flatMap((item) => (item.network === null ? [] : [item.network]));
|
||||
|
||||
export const getCompactChartBudget = (height: number): number => {
|
||||
if (height < 140) return 1;
|
||||
if (height < 260) return 2;
|
||||
return 4;
|
||||
};
|
||||
|
||||
export const getVisibleSystemCharts = ({
|
||||
configuredCharts,
|
||||
hasGpu,
|
||||
hasNetwork,
|
||||
height,
|
||||
isAdvanced,
|
||||
}: {
|
||||
configuredCharts: readonly SystemChart[];
|
||||
hasGpu: boolean;
|
||||
hasNetwork: boolean;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}): SystemChart[] => {
|
||||
const selectedCharts = isAdvanced ? ALL_SYSTEM_CHARTS : configuredCharts;
|
||||
const availableCharts = selectedCharts.filter(
|
||||
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
|
||||
);
|
||||
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height));
|
||||
};
|
||||
|
||||
export default function SystemResources({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"systemResources">) {
|
||||
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const t = useScopedI18n("widget.systemResources");
|
||||
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(healthQuery) ?? [];
|
||||
const data = results.filter(
|
||||
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
|
||||
);
|
||||
const { dataUpdatedAt, isPending } = healthQuery;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
|
||||
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>({});
|
||||
const previousIntegrationKey = useRef(integrationKey);
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousIntegrationKey.current === integrationKey) return;
|
||||
@@ -80,7 +128,7 @@ export default function SystemResources({
|
||||
|
||||
const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1;
|
||||
const panelWidth = width / panelColumns;
|
||||
const renderCharts = (entry: (typeof data)[number], availableWidth: number) => {
|
||||
const renderCharts = (entry: (typeof data)[number], availableWidth: number, availableHeight: number) => {
|
||||
const currentItem = toChartItem(entry.healthInfo);
|
||||
const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem];
|
||||
const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE);
|
||||
@@ -90,38 +138,71 @@ export default function SystemResources({
|
||||
key={entry.integrationId}
|
||||
integrationName={entry.integrationName}
|
||||
items={history}
|
||||
hasGpu={entry.healthInfo.gpu.length > 0}
|
||||
memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes}
|
||||
options={options}
|
||||
width={availableWidth}
|
||||
height={availableHeight}
|
||||
isAdvanced={isAdvanced}
|
||||
showTitle={isAdvanced || data.length > 1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isAdvanced && data.length === 1 && data[0]) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<Box h="100%" p="xs">
|
||||
{renderCharts(data[0], width)}
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<WidgetEmptyState />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdvanced && data.length === 1 && data[0]) {
|
||||
return (
|
||||
<Box h="100%" p="xs" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
{renderCharts(data[0], width, Math.max(0, height - 20))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2)));
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
|
||||
{data.map((entry) => renderCharts(entry, panelWidth))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
|
||||
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SystemChartsProps {
|
||||
integrationName: string;
|
||||
items: ChartItem[];
|
||||
hasGpu: boolean;
|
||||
memoryCapacityInBytes: number;
|
||||
options: WidgetComponentProps<"systemResources">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
showTitle: boolean;
|
||||
}
|
||||
@@ -129,53 +210,70 @@ interface SystemChartsProps {
|
||||
const SystemCharts = ({
|
||||
integrationName,
|
||||
items,
|
||||
hasGpu,
|
||||
memoryCapacityInBytes,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
isAdvanced,
|
||||
showTitle,
|
||||
}: SystemChartsProps) => {
|
||||
const showNetwork =
|
||||
items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network");
|
||||
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
|
||||
const networkItems = getNetworkHistory(items);
|
||||
const visibleCharts = getVisibleSystemCharts({
|
||||
configuredCharts: options.visibleCharts,
|
||||
hasGpu,
|
||||
hasNetwork: networkItems.length > 0,
|
||||
height,
|
||||
isAdvanced,
|
||||
});
|
||||
const showNetwork = visibleCharts.includes("network");
|
||||
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
|
||||
const chartCount = visibleCharts.length;
|
||||
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
|
||||
const chartHeight = isAdvanced
|
||||
? 180
|
||||
: `calc((100% - ${Math.max(0, chartCount - 1) * 8}px) / ${Math.max(1, chartCount)})`;
|
||||
: `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" h={isAdvanced ? "auto" : "100%"} miw={0}>
|
||||
<Stack gap="xs" h={isAdvanced ? "auto" : height} miw={0}>
|
||||
{showTitle && (
|
||||
<Text size="sm" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1 }}>
|
||||
{options.visibleCharts.includes("cpu") && (
|
||||
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1, minHeight: 0 }}>
|
||||
{chartCount === 0 && (
|
||||
<Center h="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{visibleCharts.includes("cpu") && (
|
||||
<Box h={chartHeight}>
|
||||
<SystemResourceCPUChart
|
||||
cpuUsageOverTime={items.map((item) => item.cpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{options.visibleCharts.includes("memory") && (
|
||||
{visibleCharts.includes("memory") && (
|
||||
<Box h={chartHeight}>
|
||||
<SystemResourceMemoryChart
|
||||
memoryUsageOverTime={items.map((item) => item.memory)}
|
||||
totalCapacityInBytes={memoryCapacityInBytes}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{options.visibleCharts.includes("gpu") && (
|
||||
{visibleCharts.includes("gpu") && (
|
||||
<Box h={chartHeight}>
|
||||
<SystemResourceGPUChart
|
||||
gpuUsageOverTime={items.map((item) => item.gpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -183,24 +281,24 @@ const SystemCharts = ({
|
||||
(width >= 300 ? (
|
||||
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network?.down ?? 0)}
|
||||
usageOverTime={networkItems.map((network) => network.down)}
|
||||
isUp={false}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
/>
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network?.up ?? 0)}
|
||||
usageOverTime={networkItems.map((network) => network.up)}
|
||||
isUp
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Box h={chartHeight}>
|
||||
<CombinedNetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network ?? { up: 0, down: 0 })}
|
||||
usageOverTime={networkItems}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { appendBoundedHistory, toChartItem } from "./component";
|
||||
import {
|
||||
appendBoundedHistory,
|
||||
getCompactChartBudget,
|
||||
getNetworkHistory,
|
||||
getVisibleSystemCharts,
|
||||
toChartItem,
|
||||
} from "./component";
|
||||
|
||||
describe("system resource history", () => {
|
||||
test("averages all GPU values", () => {
|
||||
@@ -18,4 +24,46 @@ describe("system resource history", () => {
|
||||
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
|
||||
expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("omits unavailable network samples instead of inventing zero traffic", () => {
|
||||
const missing = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
|
||||
const available = toChartItem({
|
||||
cpuUtilization: 1,
|
||||
memUsedInBytes: 2,
|
||||
gpu: [],
|
||||
network: { up: 12, down: 34 },
|
||||
});
|
||||
|
||||
expect(getNetworkHistory([missing, available, missing])).toEqual([{ up: 12, down: 34 }]);
|
||||
});
|
||||
|
||||
test("limits compact charts to the available height", () => {
|
||||
expect(getCompactChartBudget(100)).toBe(1);
|
||||
expect(getCompactChartBudget(200)).toBe(2);
|
||||
expect(getCompactChartBudget(300)).toBe(4);
|
||||
});
|
||||
|
||||
test("advanced mode exposes every available chart regardless of compact configuration", () => {
|
||||
expect(
|
||||
getVisibleSystemCharts({
|
||||
configuredCharts: ["cpu"],
|
||||
hasGpu: true,
|
||||
hasNetwork: true,
|
||||
height: 100,
|
||||
isAdvanced: true,
|
||||
}),
|
||||
).toEqual(["cpu", "memory", "gpu", "network"]);
|
||||
});
|
||||
|
||||
test("advanced mode omits charts whose data is unavailable", () => {
|
||||
expect(
|
||||
getVisibleSystemCharts({
|
||||
configuredCharts: ["gpu", "network"],
|
||||
hasGpu: false,
|
||||
hasNetwork: false,
|
||||
height: 300,
|
||||
isAdvanced: true,
|
||||
}),
|
||||
).toEqual(["cpu", "memory"]);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export default function TimetableWidget({
|
||||
options,
|
||||
displayMode = "compact",
|
||||
width,
|
||||
height,
|
||||
itemId,
|
||||
}: WidgetComponentProps<"timetable">) {
|
||||
const t = useScopedI18n("widget.timetable");
|
||||
@@ -27,6 +28,7 @@ export default function TimetableWidget({
|
||||
itemId={itemId}
|
||||
displayMode={displayMode}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -37,16 +39,22 @@ interface TimetableWidgetInnerProps {
|
||||
itemId?: string;
|
||||
displayMode: "compact" | "advanced";
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }: TimetableWidgetInnerProps) => {
|
||||
const { data: timetable, error } = clientApi.widget.timetable.getTimetable.useQuery({
|
||||
const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, height }: TimetableWidgetInnerProps) => {
|
||||
const {
|
||||
data: timetable,
|
||||
error,
|
||||
isPending,
|
||||
} = clientApi.widget.timetable.getTimetable.useQuery({
|
||||
baseUrl,
|
||||
itemId,
|
||||
stationId: station.value,
|
||||
limit: displayMode === "advanced" ? 50 : 10,
|
||||
});
|
||||
const t = useScopedI18n("widget.timetable");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
if (error && timetable === undefined) throw error;
|
||||
|
||||
@@ -54,10 +62,19 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }:
|
||||
const staleTime = timetable ? dayjs(timetable.timestamp).format("HH:mm:ss") : undefined;
|
||||
const staleWarning = error && staleTime ? t("warning.stale", { time: staleTime }) : undefined;
|
||||
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 300 || height < 160);
|
||||
const showTitle = isAdvanced || height >= 112;
|
||||
const showLine = isAdvanced || width >= 200;
|
||||
const showPlatform = isAdvanced || width >= 320;
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="100%" gap="xs" p="sm">
|
||||
<Text fw="bold">{t("title", { station: station.label })}</Text>
|
||||
<Stack w="100%" h="100%" gap={isDense ? 4 : "xs"} p={isDense ? "xs" : "sm"}>
|
||||
{showTitle && (
|
||||
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
|
||||
{t("title", { station: station.label })}
|
||||
</Text>
|
||||
)}
|
||||
{compactStaleWarning && displayMode === "compact" && (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-light-color)" />
|
||||
@@ -73,9 +90,29 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }:
|
||||
)}
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<SimpleGrid cols={displayMode === "advanced" && width >= 760 ? 2 : 1} spacing="xs">
|
||||
{entries.map((entry) => (
|
||||
<DepartureRow key={`${entry.timestamp.toISOString()}-${entry.location}`} entry={entry} />
|
||||
))}
|
||||
{isPending ? (
|
||||
<Center mih={64} style={{ gridColumn: "1 / -1" }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tCommon("action.loading")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : entries.length > 0 ? (
|
||||
entries.map((entry) => (
|
||||
<DepartureRow
|
||||
key={`${entry.timestamp.toISOString()}-${entry.location}`}
|
||||
entry={entry}
|
||||
dense={isDense}
|
||||
showLine={showLine}
|
||||
showPlatform={showPlatform}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Center mih={64} style={{ gridColumn: "1 / -1" }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
{displayMode === "advanced" && timetable && (
|
||||
@@ -95,11 +132,21 @@ interface TimetableEntryView {
|
||||
platform: { name: string; hasChanged: boolean } | null;
|
||||
}
|
||||
|
||||
function DepartureRow({ entry }: { entry: TimetableEntryView }) {
|
||||
function DepartureRow({
|
||||
entry,
|
||||
dense,
|
||||
showLine,
|
||||
showPlatform,
|
||||
}: {
|
||||
entry: TimetableEntryView;
|
||||
dense: boolean;
|
||||
showLine: boolean;
|
||||
showPlatform: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{entry.line && (
|
||||
<Group gap={dense ? 6 : "sm"} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showLine && entry.line && (
|
||||
<Badge
|
||||
size="sm"
|
||||
color={entry.line.color ?? undefined}
|
||||
@@ -111,21 +158,21 @@ function DepartureRow({ entry }: { entry: TimetableEntryView }) {
|
||||
{entry.line.name}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
<Text size={dense ? "xs" : "sm"} fw={dense ? 600 : undefined} style={{ whiteSpace: "nowrap" }}>
|
||||
{dayjs(entry.timestamp).format("HH:mm")}{" "}
|
||||
{entry.delay >= 1 && (
|
||||
<Text size="sm" span c="red">
|
||||
<Text size={dense ? "xs" : "sm"} span c="red">
|
||||
+{entry.delay}'
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" truncate>
|
||||
<Text size={dense ? "xs" : "sm"} truncate>
|
||||
{entry.location}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{entry.platform && (
|
||||
<Text size="sm" c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
|
||||
{showPlatform && entry.platform && (
|
||||
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
|
||||
{entry.platform.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdvancedColumnWidth } from "./component";
|
||||
import { getAdvancedColumnWidth, getCompactSectionVisibility } from "./component";
|
||||
|
||||
describe("Tracearr advanced layout", () => {
|
||||
it("subtracts grid padding and the inter-column gap", () => {
|
||||
@@ -12,3 +12,29 @@ describe("Tracearr advanced layout", () => {
|
||||
expect(getAdvancedColumnWidth(799, false)).toBe(767);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tracearr compact disclosure", () => {
|
||||
it("keeps urgent violations visible in a short widget", () => {
|
||||
expect(
|
||||
getCompactSectionVisibility({
|
||||
height: 160,
|
||||
showStreams: true,
|
||||
showViolations: true,
|
||||
hasViolations: true,
|
||||
showRecentActivity: true,
|
||||
}),
|
||||
).toEqual({ violations: true, recentActivity: false });
|
||||
});
|
||||
|
||||
it("reveals secondary history as height becomes available", () => {
|
||||
expect(
|
||||
getCompactSectionVisibility({
|
||||
height: 400,
|
||||
showStreams: true,
|
||||
showViolations: true,
|
||||
hasViolations: false,
|
||||
showRecentActivity: true,
|
||||
}),
|
||||
).toEqual({ violations: true, recentActivity: true });
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,23 @@ const ADVANCED_GRID_BREAKPOINT = 800;
|
||||
const ADVANCED_GRID_GAP_PX = 16;
|
||||
const ADVANCED_GRID_PADDING_PX = 16;
|
||||
|
||||
export const getCompactSectionVisibility = ({
|
||||
height,
|
||||
showStreams,
|
||||
showViolations,
|
||||
hasViolations,
|
||||
showRecentActivity,
|
||||
}: {
|
||||
height: number;
|
||||
showStreams: boolean;
|
||||
showViolations: boolean;
|
||||
hasViolations: boolean;
|
||||
showRecentActivity: boolean;
|
||||
}) => ({
|
||||
violations: showViolations && (hasViolations || height >= 240 || !showStreams),
|
||||
recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))),
|
||||
});
|
||||
|
||||
export default function TracearrWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
@@ -124,6 +141,13 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit);
|
||||
const violations = combined.violations?.data ?? [];
|
||||
const recentActivity = combined.recentActivity?.data ?? [];
|
||||
const compactSections = getCompactSectionVisibility({
|
||||
height,
|
||||
showStreams: options.showStreams,
|
||||
showViolations: options.showViolations,
|
||||
hasViolations: violations.length > 0,
|
||||
showRecentActivity: options.showRecentActivity,
|
||||
});
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
|
||||
@@ -154,9 +178,9 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
{options.showStats && <StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />}
|
||||
{compactSections.violations && <ViolationsList violations={violations.slice(0, compactLimit)} />}
|
||||
{options.showStreams && <StreamsList streams={streams} width={width} />}
|
||||
{options.showViolations && <ViolationsList violations={violations.slice(0, compactLimit)} />}
|
||||
{options.showRecentActivity && <RecentActivityList sessions={recentActivity.slice(0, compactLimit)} />}
|
||||
{compactSections.recentActivity && <RecentActivityList sessions={recentActivity.slice(0, compactLimit)} />}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("noSectionsEnabled")}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("recentActivity.title")}
|
||||
</Text>
|
||||
{sessions.length === 0 ? (
|
||||
|
||||
@@ -9,7 +9,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[]
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("violations.title")}
|
||||
</Text>
|
||||
{violations.length === 0 ? (
|
||||
|
||||
@@ -126,7 +126,7 @@ function TraefikWidgetContent({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{options.showEntryPoints && combined.entryPoints.length > 0 && (
|
||||
{options.showEntryPoints && combined.entryPoints.length > 0 && (displayMode === "advanced" || height >= 340) && (
|
||||
<div className={classes.entryPoints}>
|
||||
{dedupe(combined.entryPoints)
|
||||
.slice(0, getEntryPointLimit(width))
|
||||
|
||||
@@ -43,10 +43,11 @@ export function UmamiContent({
|
||||
displayMode,
|
||||
}: UmamiContentProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||
|
||||
const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery(
|
||||
const { data: results = [], isPending: isStatsPending } = clientApi.widget.umami.getVisitorStats.useQuery(
|
||||
{ integrationIds, websiteId, timeFrame, eventName },
|
||||
umamiQueryOptions,
|
||||
);
|
||||
@@ -67,6 +68,15 @@ export function UmamiContent({
|
||||
: undefined;
|
||||
|
||||
const firstResult = results[0];
|
||||
if (isStatsPending) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{tCommon("action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (!firstResult) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
@@ -93,6 +103,11 @@ export function UmamiContent({
|
||||
|
||||
const isOverlay = chartStyle === "overlay" && hasEventSeries;
|
||||
const overlayBarSize = 16;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && height < 120;
|
||||
const showXAxis = isAdvanced || height >= 140;
|
||||
const showSecondaryStats = isAdvanced || height >= 96;
|
||||
const showDetailedStats = isAdvanced || (width >= 260 && height >= 150);
|
||||
|
||||
const selectedView =
|
||||
viewMode === "events" ? (
|
||||
@@ -102,6 +117,7 @@ export function UmamiContent({
|
||||
timeFrame={timeFrame}
|
||||
eventNames={eventNames}
|
||||
chartType={chartType}
|
||||
showXAxis={showXAxis}
|
||||
/>
|
||||
) : viewMode === "topPages" ? (
|
||||
<UmamiTopPagesContent
|
||||
@@ -129,7 +145,7 @@ export function UmamiContent({
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
withTooltip
|
||||
withXAxis
|
||||
withXAxis={showXAxis}
|
||||
withYAxis={false}
|
||||
xAxisProps={{ tick: { fontSize: 9, fill: tickColor }, interval: "preserveStartEnd" }}
|
||||
/>
|
||||
@@ -143,7 +159,7 @@ export function UmamiContent({
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
withTooltip
|
||||
withXAxis
|
||||
withXAxis={showXAxis}
|
||||
withYAxis={false}
|
||||
barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }}
|
||||
barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined}
|
||||
@@ -190,7 +206,7 @@ export function UmamiContent({
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={4} p="xs" h="100%">
|
||||
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%">
|
||||
<Group justify="space-between" align="baseline" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
|
||||
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
|
||||
@@ -231,27 +247,29 @@ export function UmamiContent({
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
|
||||
<Text size="xs" c="green">
|
||||
● {activeVisitors.toLocaleString()} {t("active")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
|
||||
</Text>
|
||||
{(displayMode === "advanced" || width >= 260) && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.bounceRate}% {t("bounceRate")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
{showSecondaryStats && (
|
||||
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
|
||||
<Text size="xs" c="green">
|
||||
● {activeVisitors.toLocaleString()} {t("active")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
|
||||
</Text>
|
||||
{showDetailedStats && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.bounceRate}% {t("bounceRate")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||
{displayMode === "advanced" ? advancedContent : selectedView}
|
||||
</Box>
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user