diff --git a/public/locales/en/widgets/location.json b/public/locales/en/widgets/location.json new file mode 100644 index 000000000..b83836e20 --- /dev/null +++ b/public/locales/en/widgets/location.json @@ -0,0 +1,34 @@ +{ + "form": { + "field": { + "query": "City / postal code", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "button": { + "search": { + "label": "Search", + "disabledTooltip": "Please choose a city / postal code first" + } + }, + "empty": "Unknown location" + }, + "modal": { + "title": "Choose a location", + "table": { + "header": { + "city": "City", + "country": "Country", + "coordinates": "Coordinates", + "population": "Population" + }, + "action": { + "select": "Select {{city}}, {{countryCode}}" + }, + "population": { + "fallback": "Unknown", + "count": "{{count}} people" + } + } + } +} diff --git a/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx b/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx new file mode 100644 index 000000000..d9ca54216 --- /dev/null +++ b/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx @@ -0,0 +1,236 @@ +import { + Card, + Stack, + Text, + Title, + Group, + TextInput, + Button, + NumberInput, + Modal, + Table, + Tooltip, + ActionIcon, + Loader, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconListSearch, IconClick } from '@tabler/icons-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IntegrationOptionsValueType } from '../WidgetsEditModal'; +import { City } from '~/server/api/routers/weather'; +import { api } from '~/utils/api'; + +type LocationSelectionProps = { + widgetId: string; + propName: string; + value: any; + handleChange: (key: string, value: IntegrationOptionsValueType) => void; +}; + +export const LocationSelection = ({ + widgetId, + propName: key, + value, + handleChange, +}: LocationSelectionProps) => { + const { t } = useTranslation('widgets/location'); + const [query, setQuery] = useState(value.name ?? ''); + const [opened, { open, close }] = useDisclosure(false); + const selectionEnabled = query.length > 1; + const EMPTY_LOCATION = t('form.empty'); + + const onCitySelected = (city: City) => { + close(); + handleChange(key, { + name: city.name, + latitude: city.latitude, + longitude: city.longitude, + }); + setQuery(city.name); + }; + + return ( + <> + + + {t(`modules/${widgetId}:descriptor.settings.${key}.label`)} + + + { + setQuery(ev.currentTarget.value); + handleChange(key, { + name: ev.currentTarget.value, + longitude: '', + latitude: '', + }); + }} + /> + + + + + { + if (typeof v !== 'number') return; + handleChange(key, { + ...value, + name: EMPTY_LOCATION, + latitude: v, + }); + setQuery(EMPTY_LOCATION); + }} + precision={5} + label={t('form.field.latitude')} + hideControls + /> + { + if (typeof v !== 'number') return; + handleChange(key, { + ...value, + name: EMPTY_LOCATION, + longitude: v, + }); + setQuery(EMPTY_LOCATION); + }} + precision={5} + label={t('form.field.longitude')} + hideControls + /> + + + + + + ); +}; + +type CitySelectModalProps = { + opened: boolean; + closeModal: () => void; + query: string; + onCitySelected: (location: City) => void; +}; + +const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySelectModalProps) => { + const { t } = useTranslation('widgets/location'); + const { isLoading, data } = api.weather.findCity.useQuery( + { query }, + { + enabled: opened, + refetchOnWindowFocus: false, + refetchOnMount: false, + } + ); + + return ( + + {t('modal.title')} - {query} + + } + size="xl" + opened={opened} + onClose={closeModal} + zIndex={250} + > + + + + + + + + + + + + {isLoading && ( + + + + )} + {data?.results.map((city) => ( + + + + + + + + ))} + +
{t('modal.table.header.city')}{t('modal.table.header.country')}{t('modal.table.header.coordinates')}{t('modal.table.header.population')} +
+ + + +
+ {city.name} + + {city.country} + + + {city.latitude}, {city.longitude} + + + {city.population ? ( + + {t('modal.population.count', { count: city.population })} + + ) : ( + {t('modal.population.fallback')} + )} + + + { + onCitySelected(city); + }} + > + + + +
+ + + +
+
+ ); +}; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx index 2d8177f89..ecc817d13 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -26,6 +26,7 @@ import Widgets from '../../../../widgets'; import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets'; import { IWidget } from '../../../../widgets/widgets'; import { DraggableList } from './Inputs/DraggableList'; +import { LocationSelection } from './Inputs/LocationSelection'; import { StaticDraggableList } from './Inputs/StaticDraggableList'; export type WidgetEditModalInnerProps = { @@ -35,7 +36,7 @@ export type WidgetEditModalInnerProps = { widgetOptions: IWidget['properties']; }; -type IntegrationOptionsValueType = IWidget['properties'][string]; +export type IntegrationOptionsValueType = IWidget['properties'][string]; export const WidgetsEditModal = ({ context, @@ -200,6 +201,16 @@ const WidgetOptionTypeSwitch: FC<{ /> ); + case 'location': + return ( + + ); + case 'draggable-list': /* eslint-disable no-case-declarations */ const typedVal = value as IDraggableListInputValue['defaultValue']; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 5395df87d..4b02b83b4 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -12,6 +12,7 @@ import { mediaServerRouter } from './routers/media-server'; import { overseerrRouter } from './routers/overseerr'; import { usenetRouter } from './routers/usenet/router'; import { calendarRouter } from './routers/calendar'; +import { weatherRouter } from './routers/weather'; /** * This is the primary router for your server. @@ -32,6 +33,7 @@ export const rootRouter = createTRPCRouter({ overseerr: overseerrRouter, usenet: usenetRouter, calendar: calendarRouter, + weather: weatherRouter, }); // export type definition of API diff --git a/src/server/api/routers/weather.ts b/src/server/api/routers/weather.ts new file mode 100644 index 000000000..acb85c9ea --- /dev/null +++ b/src/server/api/routers/weather.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +const citySchema = z.object({ + id: z.number(), + name: z.string(), + country: z.string(), + country_code: z.string(), + latitude: z.number(), + longitude: z.number(), + population: z.number().optional(), +}); + +const weatherSchema = z.object({ + current_weather: z.object({ + weathercode: z.number(), + temperature: z.number(), + }), + daily: z.object({ + temperature_2m_max: z.array(z.number()), + temperature_2m_min: z.array(z.number()), + }), +}); + +export const weatherRouter = createTRPCRouter({ + findCity: publicProcedure + .input( + z.object({ + query: z.string().min(2), + }) + ) + .output( + z.object({ + results: z.array(citySchema), + }) + ) + .query(async ({ input }) => { + const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`); + return res.json(); + }), + at: publicProcedure + .input( + z.object({ + longitude: z.number(), + latitude: z.number(), + }) + ) + .output(weatherSchema) + .query(async ({ input }) => { + const res = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=Europe%2FLondon` + ); + return res.json(); + }), +}); + +export type City = z.infer; +export type Weather = z.infer; diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 2031682f7..b7f50623f 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -44,6 +44,7 @@ export const dashboardNamespaces = [ 'modules/bookmark', 'widgets/error-boundary', 'widgets/draggable-list', + 'widgets/location', ]; export const loginNamespaces = ['authentication/login']; diff --git a/src/widgets/weather/WeatherTile.tsx b/src/widgets/weather/WeatherTile.tsx index 0f42d9424..5f0440284 100644 --- a/src/widgets/weather/WeatherTile.tsx +++ b/src/widgets/weather/WeatherTile.tsx @@ -1,9 +1,9 @@ import { Center, Group, Skeleton, Stack, Text, Title } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; import { IconArrowDownRight, IconArrowUpRight, IconCloudRain } from '@tabler/icons-react'; +import { api } from '~/utils/api'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; -import { useWeatherForCity } from './useWeatherForCity'; import { WeatherIcon } from './WeatherIcon'; const definition = defineWidget({ @@ -15,8 +15,12 @@ const definition = defineWidget({ defaultValue: false, }, location: { - type: 'text', - defaultValue: 'Paris', + type: 'location', + defaultValue: { + name: 'Paris', + latitude: 48.85341, + longitude: 2.3488, + }, }, }, gridstack: { @@ -35,8 +39,8 @@ interface WeatherTileProps { } function WeatherTile({ widget }: WeatherTileProps) { - const { data: weather, isLoading, isError } = useWeatherForCity(widget.properties.location); - const { width, height, ref } = useElementSize(); + const { data: weather, isLoading, isError } = api.weather.at.useQuery(widget.properties.location); + const { width, ref } = useElementSize(); if (isLoading) { return ( @@ -77,10 +81,10 @@ function WeatherTile({ widget }: WeatherTileProps) { style={{ height: '100%', width: '100%' }} > - + {getPerferedUnit( - weather!.current_weather.temperature, + weather.current_weather.temperature, widget.properties.displayInFahrenheit )} @@ -89,12 +93,12 @@ function WeatherTile({ widget }: WeatherTileProps) { {getPerferedUnit( - weather!.daily.temperature_2m_max[0], + weather.daily.temperature_2m_max[0], widget.properties.displayInFahrenheit )} {getPerferedUnit( - weather!.daily.temperature_2m_min[0], + weather.daily.temperature_2m_min[0], widget.properties.displayInFahrenheit )} diff --git a/src/widgets/weather/types.ts b/src/widgets/weather/types.ts deleted file mode 100644 index 39fd42135..000000000 --- a/src/widgets/weather/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -// To parse this data: -// -// import { Convert, WeatherResponse } from "./file"; -// -// const weatherResponse = Convert.toWeatherResponse(json); -// -// These functions will throw an error if the JSON doesn't -// match the expected interface, even if the JSON is valid. - -export interface WeatherResponse { - current_weather: CurrentWeather; - utc_offset_seconds: number; - latitude: number; - elevation: number; - longitude: number; - generationtime_ms: number; - daily_units: DailyUnits; - daily: Daily; -} - -export interface CurrentWeather { - winddirection: number; - windspeed: number; - time: string; - weathercode: number; - temperature: number; -} - -export interface Daily { - temperature_2m_max: number[]; - time: Date[]; - temperature_2m_min: number[]; - weathercode: number[]; -} - -export interface DailyUnits { - temperature_2m_max: string; - temperature_2m_min: string; - time: string; - weathercode: string; -} diff --git a/src/widgets/weather/useWeatherForCity.ts b/src/widgets/weather/useWeatherForCity.ts deleted file mode 100644 index be7a28ac0..000000000 --- a/src/widgets/weather/useWeatherForCity.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { WeatherResponse } from './types'; - -/** - * Requests the weather of the specified city - * @param cityName name of the city where the weather should be requested - * @returns weather of specified city - */ -export const useWeatherForCity = (cityName: string) => { - const { - data: city, - isLoading, - isError, - } = useQuery({ - queryKey: ['weatherCity', { cityName }], - queryFn: () => fetchCity(cityName), - cacheTime: 1000 * 60 * 60 * 24, // the city is cached for 24 hours - staleTime: Infinity, // the city is never considered stale - }); - const weatherQuery = useQuery({ - queryKey: ['weather', { cityName }], - queryFn: () => fetchWeather(city?.results[0]), - enabled: Boolean(city), - cacheTime: 1000 * 60 * 60 * 6, // the weather is cached for 6 hours - staleTime: 1000 * 60 * 5, // the weather is considered stale after 5 minutes - }); - - return { - ...weatherQuery, - isLoading: weatherQuery.isLoading || isLoading, - isError: weatherQuery.isError || isError, - }; -}; - -/** - * Requests the coordinates of a city - * @param cityName name of city - * @returns list with all coordinates for citites with specified name - */ -const fetchCity = async (cityName: string) => { - const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${cityName}`); - return (await res.json()) as { results: Coordinates[] }; -}; - -/** - * Requests the weather of specific coordinates - * @param coordinates of the location the weather should be fetched - * @returns weather of specified coordinates - */ -async function fetchWeather(coordinates?: Coordinates) { - if (!coordinates) return null; - const { longitude, latitude } = coordinates; - const res = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=Europe%2FLondon` - ); - // eslint-disable-next-line consistent-return - return (await res.json()) as WeatherResponse; -} - -type Coordinates = { latitude: number; longitude: number }; diff --git a/src/widgets/widgets.ts b/src/widgets/widgets.ts index f40ca4a4a..be5322b60 100644 --- a/src/widgets/widgets.ts +++ b/src/widgets/widgets.ts @@ -40,7 +40,8 @@ export type IWidgetOptionValue = | INumberInputOptionValue | IDraggableListInputValue | IDraggableEditableListInputValue - | IMultipleTextInputOptionValue; + | IMultipleTextInputOptionValue + | ILocationOptionValue; // Interface for data type interface DataType { @@ -95,6 +96,11 @@ export type ISliderInputOptionValue = { inputProps?: Partial; }; +type ILocationOptionValue = { + type: 'location'; + defaultValue: { latitude: number; longitude: number }; +}; + // will show a sortable list that can have sub settings export type IDraggableListInputValue = { type: 'draggable-list';