feat(clock widget): Adds animated weather and temp (#6372)
This commit is contained in:
1 parent
9bb0c0f811
commit
7184bc09c1
6 files changed
+200
-23
No files matched your search
@@ -1905,6 +1905,16 @@
|
||||
"customDateFormat": {
|
||||
"label": "Custom date format",
|
||||
"description": "Use ISO 8601 to format date (this will override other options)"
|
||||
},
|
||||
"showWeather": {
|
||||
"label": "Show weather",
|
||||
"description": "Display a small animated weather icon and temperature in the corner of the card"
|
||||
},
|
||||
"weatherLocation": {
|
||||
"label": "Weather location"
|
||||
},
|
||||
"isWeatherFormatFahrenheit": {
|
||||
"label": "Use Fahrenheit"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, 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 type { WidgetComponentProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(utc);
|
||||
@@ -23,30 +26,65 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
const time = useCurrentTime(options);
|
||||
|
||||
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
|
||||
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||
|
||||
return (
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
{options.customTitleToggle && (
|
||||
<Text className="clock-customTitle-text" size={sizing} ta="center">
|
||||
{options.customTitle}
|
||||
</Text>
|
||||
<Box className="clock-widget-container" h="100%" pos="relative">
|
||||
{showWeatherCorner && (
|
||||
<ClockWeatherCorner
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
/>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
{options.customTimeFormat
|
||||
? dayjs(time).tz(timezone).format(customTimeFormat)
|
||||
: dayjs(time).tz(timezone).format(timeFormat)}
|
||||
</Title>
|
||||
{options.showDate && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
{options.customDateFormat
|
||||
? dayjs(time).tz(timezone).format(customDateFormat)
|
||||
: dayjs(time).tz(timezone).format(dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
{options.customTitleToggle && (
|
||||
<Text className="clock-customTitle-text" size={sizing} ta="center">
|
||||
{options.customTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
{options.customTimeFormat
|
||||
? dayjs(time).tz(timezone).format(customTimeFormat)
|
||||
: dayjs(time).tz(timezone).format(timeFormat)}
|
||||
</Title>
|
||||
{options.showDate && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
{options.customDateFormat
|
||||
? dayjs(time).tz(timezone).format(customDateFormat)
|
||||
: dayjs(time).tz(timezone).format(dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClockWeatherCornerProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
isFahrenheit: boolean;
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
|
||||
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
|
||||
if (!weather) return null;
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
|
||||
return (
|
||||
<Stack className="clock-weather-corner" pos="absolute" top={4} left={4} gap={0} align="center">
|
||||
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
|
||||
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface UseCurrentTimeProps {
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,20 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
showWeather: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
weatherLocation: factory.location({
|
||||
defaultValue: {
|
||||
name: "Brisbane",
|
||||
latitude: -27.4698,
|
||||
longitude: 153.0251,
|
||||
},
|
||||
}),
|
||||
isWeatherFormatFahrenheit: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
customTitle: {
|
||||
@@ -66,6 +80,12 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
dateFormat: {
|
||||
shouldHide: (options) => !options.showDate,
|
||||
},
|
||||
weatherLocation: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
isWeatherFormatFahrenheit: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import "../widgets-common.css";
|
||||
|
||||
import { WeatherIcon } from "./icon";
|
||||
|
||||
interface AnimatedWeatherIconProps {
|
||||
code: number;
|
||||
size?: string | number;
|
||||
}
|
||||
|
||||
|
||||
const getAnimationClass = (code: number): string => {
|
||||
if (code === 0) return "weather-anim-sun";
|
||||
if ([1, 2, 3].includes(code)) return "weather-anim-cloud";
|
||||
if ([45, 48].includes(code)) return "weather-anim-fog";
|
||||
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return "weather-anim-rain";
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code)) return "weather-anim-snow";
|
||||
if ([95, 96, 99].includes(code)) return "weather-anim-storm";
|
||||
return "";
|
||||
};
|
||||
|
||||
|
||||
export const AnimatedWeatherIcon = ({ code, size = 26 }: AnimatedWeatherIconProps) => {
|
||||
return (
|
||||
<span className={`weather-anim-wrapper ${getAnimationClass(code)}`}>
|
||||
<WeatherIcon code={code} size={size} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,8 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WeatherDescription, WeatherIcon } from "./icon";
|
||||
import { AnimatedWeatherIcon } from "./animated-icon";
|
||||
import { WeatherDescription } from "./icon";
|
||||
|
||||
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
|
||||
const input = {
|
||||
@@ -55,7 +56,7 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
||||
<HoverCard>
|
||||
<HoverCard.Target>
|
||||
<Box>
|
||||
<WeatherIcon size={30} code={weather.current.weathercode} />
|
||||
<AnimatedWeatherIcon size={30} code={weather.current.weathercode} />
|
||||
</Box>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
@@ -146,7 +147,7 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
||||
<HoverCard>
|
||||
<HoverCard.Target>
|
||||
<Box>
|
||||
<WeatherIcon size={16} code={weather.current.weathercode} />
|
||||
<AnimatedWeatherIcon size={16} code={weather.current.weathercode} />
|
||||
</Box>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
@@ -184,7 +185,7 @@ function Forecast({ weather, options }: WeatherProps) {
|
||||
align="center"
|
||||
>
|
||||
<Text fz="xl">{dayjs(dayWeather.time).format("dd")}</Text>
|
||||
<WeatherIcon size={16} code={dayWeather.weatherCode} />
|
||||
<AnimatedWeatherIcon size={16} code={dayWeather.weatherCode} />
|
||||
<Text fz={16}>
|
||||
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
||||
</Text>
|
||||
|
||||
@@ -25,3 +25,83 @@
|
||||
.text-flash {
|
||||
animation: glow 1s ease-in-out;
|
||||
}
|
||||
|
||||
/*Animated weather icons, used by clock and weather widgets*/
|
||||
.weather-anim-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@keyframes weather-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes weather-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes weather-fall {
|
||||
0% {
|
||||
transform: translateY(-2px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(2px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes weather-flicker {
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
92%,
|
||||
96% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.weather-anim-sun {
|
||||
animation: weather-spin 16s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.weather-anim-cloud,
|
||||
.weather-anim-fog {
|
||||
animation: weather-drift 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.weather-anim-rain,
|
||||
.weather-anim-snow {
|
||||
animation: weather-fall 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.weather-anim-storm {
|
||||
animation: weather-flicker 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.weather-anim-sun,
|
||||
.weather-anim-cloud,
|
||||
.weather-anim-fog,
|
||||
.weather-anim-rain,
|
||||
.weather-anim-snow,
|
||||
.weather-anim-storm {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user