feat: expand utility widgets (#6638)
This commit is contained in:
1 parent
53b518960c
commit
58a24569ce
86 files changed
+7660
-751
No files matched your search
@@ -1,51 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezones from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
import { AdvancedClockView } from "./advanced-view";
|
||||
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
|
||||
import { useCurrentTime } from "./use-current-time";
|
||||
import { ClockWeatherSummary } from "./weather-summary";
|
||||
import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezones);
|
||||
|
||||
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
|
||||
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
|
||||
const dateFormat = options.dateFormat;
|
||||
const customTimeFormat = options.customTimeFormat;
|
||||
const customDateFormat = options.customDateFormat;
|
||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
|
||||
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
|
||||
const time = useCurrentTime({ showSeconds });
|
||||
const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone();
|
||||
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
|
||||
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
|
||||
|
||||
if (isAdvanced) {
|
||||
if (time && zonedTime) {
|
||||
return (
|
||||
<AdvancedClockView
|
||||
now={time}
|
||||
options={options}
|
||||
primaryTime={zonedTime}
|
||||
primaryTimeZone={primaryTimeZone}
|
||||
primaryTimeZoneInvalid={primaryTimeZoneInvalid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Center h="100%">--:--</Center>;
|
||||
}
|
||||
|
||||
const resolvedTimeFormat = resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat);
|
||||
const minimumAxis = Math.min(width, height * 1.5);
|
||||
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
|
||||
let sizing: "xs" | "sm" | "md" = "md";
|
||||
if (minimumAxis < 128) sizing = "xs";
|
||||
else if (minimumAxis < 196) sizing = "sm";
|
||||
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||
|
||||
return (
|
||||
<Box className="clock-widget-container" h="100%" pos="relative">
|
||||
{showWeatherCorner && (
|
||||
<ClockWeatherCorner
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
showDetails={isAdvanced}
|
||||
/>
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
<ClockWeatherSummary
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
detailed={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
{options.customTitleToggle && (
|
||||
@@ -53,19 +64,14 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
{options.customTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
<Title className="clock-time-text" fw={700} order={getTitleOrder(sizing)} lh="1">
|
||||
<time dateTime={zonedTime?.toISOString()}>
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)}
|
||||
</time>
|
||||
</Title>
|
||||
{(isAdvanced || options.showDate) && (
|
||||
{options.showDate && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
|
||||
{zonedTime?.format(options.dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -73,117 +79,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
);
|
||||
}
|
||||
|
||||
interface ClockWeatherCornerProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({
|
||||
latitude,
|
||||
longitude,
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
showDetails,
|
||||
}: ClockWeatherCornerProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) {
|
||||
return (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
{isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
const today = weather.daily[0];
|
||||
const formatTemperature = (value: number | undefined) => {
|
||||
if (value === undefined) return "—";
|
||||
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="clock-weather-corner"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
gap={showDetails ? 4 : 0}
|
||||
align="flex-start"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
|
||||
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
|
||||
</Group>
|
||||
{showDetails && (
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" fw={600}>
|
||||
{locationName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
|
||||
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
|
||||
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
|
||||
|
||||
interface UseCurrentTimeProps {
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout>(null);
|
||||
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
setTime(new Date());
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTime(new Date());
|
||||
}, intervalMultiplier * 1000);
|
||||
},
|
||||
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [intervalMultiplier, showSeconds]);
|
||||
|
||||
return time;
|
||||
const getTitleOrder = (sizing: "xs" | "sm" | "md") => {
|
||||
if (sizing === "md") return 2;
|
||||
if (sizing === "sm") return 4;
|
||||
return 6;
|
||||
};
|
||||
Reference in new issue
Block a user