feat(clock widget): Adds animated weather and temp (#6372)

This commit is contained in:
Bergasha authored and GitHub committed 2026-07-23 00:04:32 +02:00
1 parent 9bb0c0f811
commit 7184bc09c1
6 files changed
+200 -23

No files matched your search

+10
View File
@@ -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"
}
}
},
+57 -19
View File
@@ -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;
}
+20
View File
@@ -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>
);
};
+5 -4
View File
@@ -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>
+80
View File
@@ -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;
}
}