Merge origin/dev into feat/widget-only-integration-refactor

Resolve integration.ts conflict (keep widget-only documentationUrl: null, take defaultUrl from dev) and regenerate pnpm-lock.yaml.
This commit is contained in:
Thomas Camlong committed 2026-07-01 14:57:00 +02:00
commit 06da6191cc
84 files changed
+4379 -2098

No files matched your search

+2
View File
@@ -8,6 +8,8 @@
transition: scale 0.2s ease-in-out;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
touch-action: manipulation;
}
.appWithUrl:hover > .appIcon {
+1
View File
@@ -87,6 +87,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
+33 -7
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -13,10 +13,12 @@ interface CalendarDayProps {
disabled: boolean;
rootWidth: number;
rootHeight: number;
onOpen: (close: () => void) => void;
}
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard();
@@ -29,6 +31,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return (
<Popover
position="bottom"
@@ -39,7 +54,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
transitionProps={{
transition: "pop",
}}
onChange={setOpened}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
opened={opened}
disabled={disabled}
>
@@ -59,10 +77,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
cursor: disabled ? "default" : "pointer",
}}
onClick={() => {
if (disabled) return;
setOpened((prev) => !prev);
if (!disabled) setPinned((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
@@ -71,7 +89,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
</Container>
</Popover.Target>
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
+50 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
@@ -16,11 +16,15 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
export default function CalendarWidget(
props: WidgetComponentProps<"calendar">,
) {
const [month, setMonth] = useState(new Date());
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
return (
<CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />
);
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -31,7 +35,13 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -61,9 +71,20 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
},
});
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const events = useMemo(
() => data?.flatMap((item) => item.events) ?? [],
[data],
);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
month={month}
setMonth={setMonth}
options={options}
/>
);
};
interface CalendarBaseProps {
@@ -74,7 +95,13 @@ interface CalendarBaseProps {
options: WidgetComponentProps<"calendar">["options"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const CalendarBase = ({
isEditMode,
events,
month,
setMonth,
options,
}: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -85,6 +112,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return (
<Calendar
@@ -150,9 +182,14 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
(event) =>
event.metadata?.type !== "radarr" ||
options.releaseType.includes(event.metadata.releaseType),
)
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
.toSorted(
(eventA, eventB) =>
eventA.startDate.getTime() - eventB.startDate.getTime(),
);
return (
<CalendarDay
@@ -161,6 +198,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
onOpen={onDayOpen}
/>
);
}}
@@ -198,7 +236,9 @@ export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate)
? event.endDate
: currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
+32 -1
View File
@@ -1,3 +1,4 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
disabled?: boolean;
color?: string;
}
export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -56,6 +85,7 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export interface WidgetProps<TKind extends WidgetKind> {
@@ -70,6 +100,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
@@ -57,6 +57,7 @@ export default function DnsHoleControlsWidget({
},
{
staleTime: 5 * 1000,
refetchInterval: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -26,6 +26,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
},
{
staleTime: 5 * 1000,
refetchInterval: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
+21 -2
View File
@@ -1,10 +1,10 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
@@ -213,6 +213,15 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
state: options.columns.includes("state"),
host: options.columns.includes("host"),
cpuUsage: options.columns.includes("cpuUsage"),
memoryUsage: options.columns.includes("memoryUsage"),
actions: options.columns.includes("actions"),
};
const table = useTranslatedMantineReactTable({
columns,
data: containers,
@@ -233,6 +242,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
},
state: {
columnVisibility,
},
mantinePaperProps: {
flex: 1,
withBorder: false,
@@ -260,6 +272,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
},
});
if (options.columns.length === 0)
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
</Center>
);
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
+19
View File
@@ -12,10 +12,29 @@ const columnsList = [
"memoryUsage",
] as const satisfies (keyof RouterOutputs["docker"]["getContainers"]["containers"][number])[];
const allColumnsList = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const columnTranslationKeyMap = {
name: "docker.field.name.label",
state: "docker.field.state.label",
host: "docker.field.host.label",
cpuUsage: "docker.field.stats.cpu.label",
memoryUsage: "docker.field.stats.memory.label",
actions: "docker.action.title",
} as const satisfies Record<(typeof allColumnsList)[number], string>;
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
+6 -1
View File
@@ -61,7 +61,12 @@ import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type {
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -1,6 +1,7 @@
import { IconZoomQuestion } from "@tabler/icons-react";
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
}),
}));
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
@@ -1,6 +1,7 @@
import { IconChartBar } from "@tabler/icons-react";
import { IconChartBar, IconSearch } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
@@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
+15 -1
View File
@@ -1,5 +1,6 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
@@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!canChange || isEditing) return;
// Ignore double-clicks bubbling up from interactive controls (e.g. the
// edit/save ActionIcon), which would otherwise toggle edit mode twice.
if (event.target instanceof Element && event.target.closest("button, a")) {
return;
}
setIsEditing(handleEditToggleCallback);
},
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
);
return (
<Box h="100%">
<Box h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor
p={0}
mt={0}
+2
View File
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
skipContextMenu?: boolean;
}
interface TextInput extends CommonInput<string> {
@@ -92,6 +93,7 @@ const optionsFactory = {
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false,
skipContextMenu: input?.skipContextMenu ?? false,
}),
text: (input?: TextInput) => ({
type: "text" as const,
+2 -2
View File
@@ -255,7 +255,7 @@ export default function ReleasesWidget({ options, itemId }: WidgetComponentProps
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
{hasError ? (
@@ -567,7 +567,7 @@ const ExpandedDisplay = ({
<Text className="releases-repository-expanded-createdAt-date" span fw={700}>
{formatter.relativeTime(repository.createdAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
</Text>