338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
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 { useSettings } from "@homarr/settings";
|
|
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|
|
|
import actionTargetClasses from "../common/action-target.module.css";
|
|
import type { WidgetComponentProps } from "../definition";
|
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
|
import { CalendarDay } from "./calender-day";
|
|
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) {
|
|
return (
|
|
<CalendarBase
|
|
{...props}
|
|
events={[]}
|
|
failedIntegrations={[]}
|
|
isPending={false}
|
|
month={month}
|
|
setMonth={setMonth}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
|
|
}
|
|
|
|
interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
|
month: Date;
|
|
setMonth: (date: Date) => void;
|
|
}
|
|
|
|
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
|
const input = {
|
|
integrationIds,
|
|
month: month.getMonth() + 1,
|
|
year: month.getFullYear(),
|
|
releaseType: options.releaseType,
|
|
showUnmonitored: options.showUnmonitored,
|
|
};
|
|
const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
|
const data = getUsableWidgetQueryData(calendarQuery);
|
|
const { isPending } = calendarQuery;
|
|
|
|
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 }] : [],
|
|
) ?? [];
|
|
|
|
return (
|
|
<CalendarBase
|
|
isEditMode={isEditMode}
|
|
events={events}
|
|
failedIntegrations={failedIntegrations}
|
|
isPending={isPending}
|
|
month={month}
|
|
setMonth={setMonth}
|
|
options={options}
|
|
displayMode={displayMode}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface CalendarBaseProps {
|
|
isEditMode: boolean;
|
|
events: CalendarEventWithSource[];
|
|
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
|
isPending: boolean;
|
|
month: Date;
|
|
setMonth: (date: Date) => void;
|
|
options: WidgetComponentProps<"calendar">["options"];
|
|
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
|
}
|
|
|
|
const CalendarBase = ({
|
|
isEditMode,
|
|
events,
|
|
failedIntegrations,
|
|
isPending,
|
|
month,
|
|
setMonth,
|
|
options,
|
|
displayMode,
|
|
}: CalendarBaseProps) => {
|
|
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 normalizedEvents = useMemo(
|
|
() => (displayMode === "advanced" ? [] : splitEvents(events)),
|
|
[displayMode, events],
|
|
);
|
|
const visibleEvents = useMemo(
|
|
() =>
|
|
normalizedEvents.filter(
|
|
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
|
),
|
|
[normalizedEvents, options.releaseType],
|
|
);
|
|
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
|
|
const agendaEvents = useMemo(
|
|
() => (displayMode === "advanced" ? getCalendarAgendaEvents(events, month, options.releaseType) : []),
|
|
[displayMode, events, month, options.releaseType],
|
|
);
|
|
|
|
if (displayMode === "advanced") {
|
|
return (
|
|
<CalendarAgenda
|
|
events={agendaEvents}
|
|
failedIntegrations={failedIntegrations}
|
|
isPending={isPending}
|
|
isEditMode={isEditMode}
|
|
locale={locale}
|
|
month={month}
|
|
setMonth={setMonth}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
|
<Calendar
|
|
defaultDate={new Date()}
|
|
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
|
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
|
|
highlightToday
|
|
locale={locale}
|
|
hideWeekdays={false}
|
|
date={month}
|
|
maxLevel="month"
|
|
firstDayOfWeek={firstDayOfWeek}
|
|
className={classes.calendar}
|
|
w="100%"
|
|
h="100%"
|
|
styles={{
|
|
calendarHeaderControl: {
|
|
borderRadius: "md",
|
|
height: isSmall ? "1.5rem" : undefined,
|
|
width: isSmall ? "1.5rem" : undefined,
|
|
},
|
|
calendarHeaderLevel: {
|
|
pointerEvents: "none",
|
|
fontSize: isSmall ? "0.75rem" : undefined,
|
|
height: "100%",
|
|
},
|
|
levelsGroup: {
|
|
height: "100%",
|
|
padding: "md",
|
|
},
|
|
calendarHeader: {
|
|
maxWidth: "unset",
|
|
marginBottom: 0,
|
|
},
|
|
monthCell: {
|
|
textAlign: "center",
|
|
position: "relative",
|
|
},
|
|
day: {
|
|
borderRadius: actualItemRadius,
|
|
width: "100%",
|
|
height: "100%",
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 0,
|
|
right: 0,
|
|
},
|
|
month: {
|
|
height: "100%",
|
|
},
|
|
weekday: {
|
|
padding: 0,
|
|
},
|
|
weekdaysRow: {
|
|
height: 22,
|
|
},
|
|
}}
|
|
renderDay={(tileDate) => {
|
|
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
|
|
|
|
return (
|
|
<CalendarDay
|
|
date={dayjs(tileDate).toDate()}
|
|
events={eventsForDate}
|
|
disabled={eventsForDate.length === 0}
|
|
rootWidth={width}
|
|
rootHeight={height}
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
{failedIntegrations.length > 0 && (
|
|
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
|
</Group>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
interface CalendarAgendaProps {
|
|
events: CalendarEventWithSource[];
|
|
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
|
isPending: boolean;
|
|
isEditMode: boolean;
|
|
locale: string;
|
|
month: Date;
|
|
setMonth: (date: Date) => void;
|
|
}
|
|
|
|
const CalendarAgenda = ({
|
|
events,
|
|
failedIntegrations,
|
|
isPending,
|
|
isEditMode,
|
|
locale,
|
|
month,
|
|
setMonth,
|
|
}: CalendarAgendaProps) => {
|
|
const t = useI18n("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="var(--mantine-font-size-lg)" 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="var(--mantine-font-size-lg)" aria-hidden />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
<Group gap={0}>
|
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
|
</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>
|
|
);
|
|
};
|