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:
84 files changed
+4379
-2098
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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"));
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user