mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
✨ Improve location selection for weather
This commit is contained in:
34
public/locales/en/widgets/location.json
Normal file
34
public/locales/en/widgets/location.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Card>
|
||||
<Stack spacing="xs">
|
||||
<Title order={5}>{t(`modules/${widgetId}:descriptor.settings.${key}.label`)}</Title>
|
||||
|
||||
<Group noWrap align="end">
|
||||
<TextInput
|
||||
w="100%"
|
||||
label={t('form.field.query')}
|
||||
value={query}
|
||||
onChange={(ev) => {
|
||||
setQuery(ev.currentTarget.value);
|
||||
handleChange(key, {
|
||||
name: ev.currentTarget.value,
|
||||
longitude: '',
|
||||
latitude: '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Tooltip hidden={selectionEnabled} label={t('form.button.search.disabledTooltip')}>
|
||||
<div>
|
||||
<Button
|
||||
disabled={!selectionEnabled}
|
||||
onClick={() => {
|
||||
if (selectionEnabled) open();
|
||||
}}
|
||||
variant="light"
|
||||
leftIcon={<IconListSearch size={16} />}
|
||||
>
|
||||
{t('form.button.search.label')}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
value={value.latitude}
|
||||
onChange={(v) => {
|
||||
if (typeof v !== 'number') return;
|
||||
handleChange(key, {
|
||||
...value,
|
||||
name: EMPTY_LOCATION,
|
||||
latitude: v,
|
||||
});
|
||||
setQuery(EMPTY_LOCATION);
|
||||
}}
|
||||
precision={5}
|
||||
label={t('form.field.latitude')}
|
||||
hideControls
|
||||
/>
|
||||
<NumberInput
|
||||
value={value.longitude}
|
||||
onChange={(v) => {
|
||||
if (typeof v !== 'number') return;
|
||||
handleChange(key, {
|
||||
...value,
|
||||
name: EMPTY_LOCATION,
|
||||
longitude: v,
|
||||
});
|
||||
setQuery(EMPTY_LOCATION);
|
||||
}}
|
||||
precision={5}
|
||||
label={t('form.field.longitude')}
|
||||
hideControls
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
<CitySelectModal
|
||||
opened={opened}
|
||||
closeModal={close}
|
||||
query={query}
|
||||
onCitySelected={onCitySelected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={
|
||||
<Title order={4}>
|
||||
{t('modal.title')} - {query}
|
||||
</Title>
|
||||
}
|
||||
size="xl"
|
||||
opened={opened}
|
||||
onClose={closeModal}
|
||||
zIndex={250}
|
||||
>
|
||||
<Stack>
|
||||
<Table striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '70%' }}>{t('modal.table.header.city')}</th>
|
||||
<th style={{ width: '50%' }}>{t('modal.table.header.country')}</th>
|
||||
<th>{t('modal.table.header.coordinates')}</th>
|
||||
<th>{t('modal.table.header.population')}</th>
|
||||
<th style={{ width: 40 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Group position="center">
|
||||
<Loader />
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.results.map((city) => (
|
||||
<tr key={city.id}>
|
||||
<td>
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>{city.name}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>{city.country}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>
|
||||
{city.latitude}, {city.longitude}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
{city.population ? (
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>
|
||||
{t('modal.population.count', { count: city.population })}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="dimmed"> {t('modal.population.fallback')}</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Tooltip
|
||||
label={t('modal.table.action.select', {
|
||||
city: city.name,
|
||||
countryCode: city.country_code,
|
||||
})}
|
||||
>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
onCitySelected(city);
|
||||
}}
|
||||
>
|
||||
<IconClick size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Group position="right">
|
||||
<Button variant="light" onClick={() => closeModal()}>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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<string, any>['properties'];
|
||||
};
|
||||
|
||||
type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
|
||||
export type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
|
||||
|
||||
export const WidgetsEditModal = ({
|
||||
context,
|
||||
@@ -200,6 +201,16 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
case 'location':
|
||||
return (
|
||||
<LocationSelection
|
||||
propName={key}
|
||||
value={value}
|
||||
handleChange={handleChange}
|
||||
widgetId={widgetId}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'draggable-list':
|
||||
/* eslint-disable no-case-declarations */
|
||||
const typedVal = value as IDraggableListInputValue['defaultValue'];
|
||||
|
||||
@@ -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
|
||||
|
||||
58
src/server/api/routers/weather.ts
Normal file
58
src/server/api/routers/weather.ts
Normal file
@@ -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<typeof citySchema>;
|
||||
export type Weather = z.infer<typeof weatherSchema>;
|
||||
@@ -44,6 +44,7 @@ export const dashboardNamespaces = [
|
||||
'modules/bookmark',
|
||||
'widgets/error-boundary',
|
||||
'widgets/draggable-list',
|
||||
'widgets/location',
|
||||
];
|
||||
|
||||
export const loginNamespaces = ['authentication/login'];
|
||||
|
||||
@@ -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%' }}
|
||||
>
|
||||
<Group align="center" position="center" spacing="xs">
|
||||
<WeatherIcon code={weather!.current_weather.weathercode} />
|
||||
<WeatherIcon code={weather.current_weather.weathercode} />
|
||||
<Title>
|
||||
{getPerferedUnit(
|
||||
weather!.current_weather.temperature,
|
||||
weather.current_weather.temperature,
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
</Title>
|
||||
@@ -89,12 +93,12 @@ function WeatherTile({ widget }: WeatherTileProps) {
|
||||
<Group noWrap spacing="xs">
|
||||
<IconArrowUpRight />
|
||||
{getPerferedUnit(
|
||||
weather!.daily.temperature_2m_max[0],
|
||||
weather.daily.temperature_2m_max[0],
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
<IconArrowDownRight />
|
||||
{getPerferedUnit(
|
||||
weather!.daily.temperature_2m_min[0],
|
||||
weather.daily.temperature_2m_min[0],
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -40,7 +40,8 @@ export type IWidgetOptionValue =
|
||||
| INumberInputOptionValue
|
||||
| IDraggableListInputValue
|
||||
| IDraggableEditableListInputValue<any>
|
||||
| IMultipleTextInputOptionValue;
|
||||
| IMultipleTextInputOptionValue
|
||||
| ILocationOptionValue;
|
||||
|
||||
// Interface for data type
|
||||
interface DataType {
|
||||
@@ -95,6 +96,11 @@ export type ISliderInputOptionValue = {
|
||||
inputProps?: Partial<SliderProps>;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user