Merge pull request #1044 from ajnart/add-location-selection-for-weather-widget

 Improve location selection for weather
This commit is contained in:
Meier Lukas
2023-06-13 20:50:34 +02:00
committed by GitHub
15 changed files with 814 additions and 507 deletions

View File

@@ -1,389 +1,393 @@
{
"schemaVersion": 1,
"configProperties": {
"name": "default"
},
"categories": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f",
"position": 1,
"name": "Welcome to Homarr 🎉",
"type": "category"
}
],
"wrappers": [
{
"id": "default",
"position": 0
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a326",
"position": 1
}
],
"apps": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Discord",
"url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": {
"onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningNewTab": true,
"externalUrl": "https://discord.com/invite/aCsmEV5RgA"
},
"network": {
"enabledStatusChecker": false,
"statusCodes": [
"200"
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 3,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://ko-fi.com/ajnart",
"behaviour": {
"onClickUrl": "https://ko-fi.com/ajnart",
"externalUrl": "https://ko-fi.com/ajnart",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"statusCodes": [
"200"
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
"name": "Contribute",
"url": "https://github.com/ajnart/homarr",
"behaviour": {
"onClickUrl": "https://github.com/ajnart/homarr",
"externalUrl": "https://github.com/ajnart/homarr",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"statusCodes": []
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 2
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 2
}
}
}
},
{
"id": "5df743d9-5cb1-457c-85d2-64ff86855652",
"name": "Documentation",
"url": "https://homarr.dev",
"behaviour": {
"onClickUrl": "https://homarr.dev",
"externalUrl": "https://homarr.dev",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"statusCodes": [
"200"
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"widgets": [
{
"id": "971aa859-8570-49a1-8d34-dd5c7b3638d1",
"type": "date",
"properties": {
"display24HourFormat": true
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"sm": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"md": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
},
{
"id": "e3004052-6b83-480e-b458-56e8ccdca5f0",
"type": "weather",
"properties": {
"displayInFahrenheit": false,
"location": "Paris"
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"settings": {
"common": {
"searchEngine": {
"type": "google",
"properties": {}
}
},
"customization": {
"layout": {
"enabledLeftSidebar": false,
"enabledRightSidebar": false,
"enabledDocker": false,
"enabledPing": false,
"enabledSearchbar": true
},
"pageTitle": "Homarr v0.12 ⭐️",
"logoImageUrl": "/imgs/logo/logo.png",
"faviconUrl": "/imgs/favicon/favicon-squared.png",
"backgroundImageUrl": "",
"customCss": "",
"colors": {
"primary": "red",
"secondary": "yellow",
"shade": 7
},
"appOpacity": 100
}
"schemaVersion": 1,
"configProperties": {
"name": "default"
},
"categories": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f",
"position": 1,
"name": "Welcome to Homarr 🎉",
"type": "category"
}
],
"wrappers": [
{
"id": "default",
"position": 0
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a326",
"position": 1
}
],
"apps": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Discord",
"url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": {
"onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningNewTab": true,
"externalUrl": "https://discord.com/invite/aCsmEV5RgA"
},
"network": {
"enabledStatusChecker": false,
"statusCodes": [
"200"
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 3,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://ko-fi.com/ajnart",
"behaviour": {
"onClickUrl": "https://ko-fi.com/ajnart",
"externalUrl": "https://ko-fi.com/ajnart",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"statusCodes": [
"200"
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
"name": "Contribute",
"url": "https://github.com/ajnart/homarr",
"behaviour": {
"onClickUrl": "https://github.com/ajnart/homarr",
"externalUrl": "https://github.com/ajnart/homarr",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"statusCodes": []
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 2
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 2
}
}
}
},
{
"id": "5df743d9-5cb1-457c-85d2-64ff86855652",
"name": "Documentation",
"url": "https://homarr.dev",
"behaviour": {
"onClickUrl": "https://homarr.dev",
"externalUrl": "https://homarr.dev",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"statusCodes": [
"200"
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"widgets": [
{
"id": "971aa859-8570-49a1-8d34-dd5c7b3638d1",
"type": "date",
"properties": {
"display24HourFormat": true
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"sm": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"md": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
},
{
"id": "e3004052-6b83-480e-b458-56e8ccdca5f0",
"type": "weather",
"properties": {
"displayInFahrenheit": false,
"location": {
"name": "Paris",
"latitude": 48.85341,
"longitude": 2.3488
}
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"settings": {
"common": {
"searchEngine": {
"type": "google",
"properties": {}
}
},
"customization": {
"layout": {
"enabledLeftSidebar": false,
"enabledRightSidebar": false,
"enabledDocker": false,
"enabledPing": false,
"enabledSearchbar": true
},
"pageTitle": "Homarr v0.12 ⭐️",
"logoImageUrl": "/imgs/logo/logo.png",
"faviconUrl": "/imgs/favicon/favicon-squared.png",
"backgroundImageUrl": "",
"customCss": "",
"colors": {
"primary": "red",
"secondary": "yellow",
"shade": 7
},
"appOpacity": 100
}
}
}

View File

@@ -0,0 +1,33 @@
{
"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"
}
}
}
}

View File

@@ -0,0 +1,234 @@
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 emptyLocation = 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={(inputValue) => {
if (typeof inputValue !== 'number') return;
handleChange(key, {
...value,
name: emptyLocation,
latitude: inputValue,
});
setQuery(emptyLocation);
}}
precision={5}
label={t('form.field.latitude')}
hideControls
/>
<NumberInput
value={value.longitude}
onChange={(inputValue) => {
if (typeof inputValue !== 'number') return;
handleChange(key, {
...value,
name: emptyLocation,
longitude: inputValue,
});
setQuery(emptyLocation);
}}
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' }}>{city.population}</Text>
) : (
<Text color="dimmed"> {t('modal.table.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>
);
};

View File

@@ -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'];

View File

@@ -38,7 +38,7 @@ export async function getServerSideProps({
};
}
const config = getFrontendConfig(configName as string);
const config = await getFrontendConfig(configName as string);
setCookie('config-name', configName, {
req,
res,

View File

@@ -47,7 +47,7 @@ export async function getServerSideProps({
}
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
const config = getFrontendConfig(configName as string);
const config = await getFrontendConfig(configName as string);
return {
props: {

View File

@@ -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

View File

@@ -0,0 +1,64 @@
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 }) => fetchCity(input.query)),
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&current_weather=true&timezone=Europe%2FLondon`
);
return res.json();
}),
});
export type City = z.infer<typeof citySchema>;
export type Weather = z.infer<typeof weatherSchema>;
const outputSchema = z.object({
results: z.array(citySchema),
});
export const fetchCity = async (query: string) => {
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${query}`);
return outputSchema.parse(await res.json());
};

View File

@@ -1,10 +1,19 @@
import Consola from 'consola';
import { ConfigType } from '../../types/config';
import fs from 'fs';
import { BackendConfigType, ConfigType } from '../../types/config';
import { getConfig } from './getConfig';
import { fetchCity } from '~/server/api/routers/weather';
export const getFrontendConfig = (name: string): ConfigType => {
const config = getConfig(name);
export const getFrontendConfig = async (name: string): Promise<ConfigType> => {
let config = getConfig(name);
const anyWeatherWidgetWithStringLocation = config.widgets.some(
(widget) => widget.type === 'weather' && typeof widget.properties.location === 'string'
);
if (anyWeatherWidgetWithStringLocation) {
config = await migrateLocation(config);
}
Consola.info(`Requested frontend content of configuration '${name}'`);
// If not, return the config
@@ -41,3 +50,39 @@ export const getFrontendConfig = (name: string): ConfigType => {
})),
};
};
const migrateLocation = async (config: BackendConfigType) => {
Consola.log('Migrating config file to new location schema...', config.configProperties.name);
const configName = config.configProperties.name;
const migratedConfig = {
...config,
widgets: await Promise.all(
config.widgets.map(async (widget) =>
widget.type !== 'weather' || typeof widget.properties.location !== 'string'
? widget
: {
...widget,
properties: {
...widget.properties,
location: await fetchCity(widget.properties.location)
.then(({ results }) => ({
name: results[0].name,
latitude: results[0].latitude,
longitude: results[0].longitude,
}))
.catch(() => ({
name: '',
latitude: 0,
longitude: 0,
})),
},
}
)
),
};
fs.writeFileSync(`./data/configs/${configName}.json`, JSON.stringify(migratedConfig, null, 2));
return migratedConfig;
};

View File

@@ -2,13 +2,11 @@ import Consola from 'consola';
import { v4 as uuidv4 } from 'uuid';
import { Config, serviceItem } from '../types';
import { ConfigAppIntegrationType, ConfigAppType, IntegrationType } from '../../types/app';
import { AreaType } from '../../types/area';
import { CategoryType } from '../../types/category';
import { BackendConfigType } from '../../types/config';
import { SearchEngineCommonSettingsType } from '../../types/settings';
import { IWidget } from '../../widgets/widgets';
import { ICalendarWidget } from '../../widgets/calendar/CalendarTile';
import { IDashDotTile } from '../../widgets/dashDot/DashDotTile';
import { IDateWidget } from '../../widgets/date/DateTile';
@@ -16,6 +14,8 @@ import { ITorrentNetworkTraffic } from '../../widgets/download-speed/TorrentNetw
import { ITorrent } from '../../widgets/torrent/TorrentTile';
import { IUsenetWidget } from '../../widgets/useNet/UseNetTile';
import { IWeatherWidget } from '../../widgets/weather/WeatherTile';
import { IWidget } from '../../widgets/widgets';
import { Config, serviceItem } from '../types';
export function migrateConfig(config: Config): BackendConfigType {
const newConfig: BackendConfigType = {
@@ -208,7 +208,11 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
type: 'weather',
properties: {
displayInFahrenheit: oldModule.options?.freedomunit?.value ?? false,
location: oldModule.options?.location?.value ?? 'Paris',
location: {
name: oldModule.options?.location?.value ?? '',
latitude: 0,
longitude: 0,
},
},
area: {
type: 'wrapper',

View File

@@ -44,6 +44,7 @@ export const dashboardNamespaces = [
'modules/bookmark',
'widgets/error-boundary',
'widgets/draggable-list',
'widgets/location',
];
export const loginNamespaces = ['authentication/login'];

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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&current_weather=true&timezone=Europe%2FLondon`
);
// eslint-disable-next-line consistent-return
return (await res.json()) as WeatherResponse;
}
type Coordinates = { latitude: number; longitude: number };

View File

@@ -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';