diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 391b235b9..681166967 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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" } } }, diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 417585162..dd43e0380 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -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 ( - - {options.customTitleToggle && ( - - {options.customTitle} - + + {showWeatherCorner && ( + )} - - {options.customTimeFormat - ? dayjs(time).tz(timezone).format(customTimeFormat) - : dayjs(time).tz(timezone).format(timeFormat)} - - {options.showDate && ( - - {options.customDateFormat - ? dayjs(time).tz(timezone).format(customDateFormat) - : dayjs(time).tz(timezone).format(dateFormat)} - - )} - + + {options.customTitleToggle && ( + + {options.customTitle} + + )} + + {options.customTimeFormat + ? dayjs(time).tz(timezone).format(customTimeFormat) + : dayjs(time).tz(timezone).format(timeFormat)} + + {options.showDate && ( + + {options.customDateFormat + ? dayjs(time).tz(timezone).format(customDateFormat) + : dayjs(time).tz(timezone).format(dateFormat)} + + )} + + ); } +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 ( + + + + {Math.round(temp)} + {unit} + + + ); +}; + interface UseCurrentTimeProps { showSeconds: boolean; } diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index d80055a89..20b22d6a0 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -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, + }, }, ); }, diff --git a/packages/widgets/src/weather/animated-icon.tsx b/packages/widgets/src/weather/animated-icon.tsx new file mode 100644 index 000000000..70145b4b1 --- /dev/null +++ b/packages/widgets/src/weather/animated-icon.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 27ab5d19e..0329597b1 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -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) => { - + @@ -146,7 +147,7 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => { - + @@ -184,7 +185,7 @@ function Forecast({ weather, options }: WeatherProps) { align="center" > {dayjs(dayWeather.time).format("dd")} - + {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} diff --git a/packages/widgets/src/widgets-common.css b/packages/widgets/src/widgets-common.css index 7c75ebf0a..7ea969ef1 100644 --- a/packages/widgets/src/widgets-common.css +++ b/packages/widgets/src/widgets-common.css @@ -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; + } +}