feat: add weather widget (#286)
* feat: add nestjs replacement, remove nestjs * feat: add weather widget * fix: lock issue * fix: format issue * fix: deepsource issues * fix: change timezone to auto
This commit is contained in:
@@ -5,6 +5,7 @@ import type { WidgetKind } from "@homarr/definitions";
|
|||||||
import { Center } from "@homarr/ui";
|
import { Center } from "@homarr/ui";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
|
import { env } from "~/env.mjs";
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,7 +13,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function WidgetPreview(props: Props) {
|
export default async function WidgetPreview(props: Props) {
|
||||||
if (!(props.params.kind in widgetImports)) {
|
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const env = createEnv({
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((url) => (url ? `https://${url}` : undefined)),
|
.transform((url) => (url ? `https://${url}` : undefined)),
|
||||||
PORT: z.coerce.number().default(3000),
|
PORT: z.coerce.number().default(3000),
|
||||||
|
NODE_ENV: z
|
||||||
|
.enum(["development", "production", "test"])
|
||||||
|
.default("development"),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
||||||
@@ -49,6 +52,7 @@ export const env = createEnv({
|
|||||||
DB_NAME: process.env.DB_NAME,
|
DB_NAME: process.env.DB_NAME,
|
||||||
DB_PORT: process.env.DB_PORT,
|
DB_PORT: process.env.DB_PORT,
|
||||||
DB_DRIVER: process.env.DB_DRIVER,
|
DB_DRIVER: process.env.DB_DRIVER,
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
|
import { locationRouter } from "./router/location";
|
||||||
import { logRouter } from "./router/log";
|
import { logRouter } from "./router/log";
|
||||||
import { userRouter } from "./router/user";
|
import { userRouter } from "./router/user";
|
||||||
|
import { widgetRouter } from "./router/widgets";
|
||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -10,6 +12,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
app: innerAppRouter,
|
app: innerAppRouter,
|
||||||
|
widget: widgetRouter,
|
||||||
|
location: locationRouter,
|
||||||
log: logRouter,
|
log: logRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
18
packages/api/src/router/location.ts
Normal file
18
packages/api/src/router/location.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const locationRouter = createTRPCRouter({
|
||||||
|
searchCity: publicProcedure
|
||||||
|
.input(validation.location.searchCity.input)
|
||||||
|
.output(validation.location.searchCity.output)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as z.infer<
|
||||||
|
typeof validation.location.searchCity.output
|
||||||
|
>;
|
||||||
|
}),
|
||||||
|
});
|
||||||
6
packages/api/src/router/widgets/index.ts
Normal file
6
packages/api/src/router/widgets/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from "../../trpc";
|
||||||
|
import { weatherRouter } from "./weather";
|
||||||
|
|
||||||
|
export const widgetRouter = createTRPCRouter({
|
||||||
|
weather: weatherRouter,
|
||||||
|
});
|
||||||
15
packages/api/src/router/widgets/weather.ts
Normal file
15
packages/api/src/router/widgets/weather.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
export const weatherRouter = createTRPCRouter({
|
||||||
|
atLocation: publicProcedure
|
||||||
|
.input(validation.widget.weather.atLocationInput)
|
||||||
|
.output(validation.widget.weather.atLocationOutput)
|
||||||
|
.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=auto`,
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -376,12 +376,63 @@ export default {
|
|||||||
description:
|
description:
|
||||||
"Displays the current weather information of a set location.",
|
"Displays the current weather information of a set location.",
|
||||||
option: {
|
option: {
|
||||||
|
isFormatFahrenheit: {
|
||||||
|
label: "Temperature in Fahrenheit",
|
||||||
|
},
|
||||||
location: {
|
location: {
|
||||||
label: "Location",
|
label: "Weather location",
|
||||||
},
|
},
|
||||||
showCity: {
|
showCity: {
|
||||||
label: "Show city",
|
label: "Show city",
|
||||||
},
|
},
|
||||||
|
hasForecast: {
|
||||||
|
label: "Show forecast",
|
||||||
|
},
|
||||||
|
forecastDayCount: {
|
||||||
|
label: "Amount of forecast days",
|
||||||
|
description:
|
||||||
|
"When the widget is not wide enough, less days are shown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
clear: "Clear",
|
||||||
|
mainlyClear: "Mainly clear",
|
||||||
|
fog: "Fog",
|
||||||
|
drizzle: "Drizzle",
|
||||||
|
freezingDrizzle: "Freezing drizzle",
|
||||||
|
rain: "Rain",
|
||||||
|
freezingRain: "Freezing rain",
|
||||||
|
snowFall: "Snow fall",
|
||||||
|
snowGrains: "Snow grains",
|
||||||
|
rainShowers: "Rain showers",
|
||||||
|
snowShowers: "Snow showers",
|
||||||
|
thunderstorm: "Thunderstorm",
|
||||||
|
thunderstormWithHail: "Thunderstorm with hail",
|
||||||
|
unknown: "Unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
location: {
|
||||||
|
query: "City / Postal code",
|
||||||
|
latitude: "Latitude",
|
||||||
|
longitude: "Longitude",
|
||||||
|
disabledTooltip: "Please enter a city or postal code",
|
||||||
|
unknownLocation: "Unknown location",
|
||||||
|
search: "Search",
|
||||||
|
table: {
|
||||||
|
header: {
|
||||||
|
city: "City",
|
||||||
|
country: "Country",
|
||||||
|
coordinates: "Coordinates",
|
||||||
|
population: "Population",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
select: "Select {city}, {countryCode}",
|
||||||
|
},
|
||||||
|
population: {
|
||||||
|
fallback: "Unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { appSchemas } from "./app";
|
import { appSchemas } from "./app";
|
||||||
import { boardSchemas } from "./board";
|
import { boardSchemas } from "./board";
|
||||||
import { integrationSchemas } from "./integration";
|
import { integrationSchemas } from "./integration";
|
||||||
|
import { locationSchemas } from "./location";
|
||||||
import { userSchemas } from "./user";
|
import { userSchemas } from "./user";
|
||||||
|
import { widgetSchemas } from "./widgets";
|
||||||
|
|
||||||
export const validation = {
|
export const validation = {
|
||||||
user: userSchemas,
|
user: userSchemas,
|
||||||
integration: integrationSchemas,
|
integration: integrationSchemas,
|
||||||
board: boardSchemas,
|
board: boardSchemas,
|
||||||
app: appSchemas,
|
app: appSchemas,
|
||||||
|
widget: widgetSchemas,
|
||||||
|
location: locationSchemas,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { createSectionSchema, sharedItemSchema } from "./shared";
|
export { createSectionSchema, sharedItemSchema } from "./shared";
|
||||||
|
|||||||
26
packages/validation/src/location.ts
Normal file
26
packages/validation/src/location.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const citySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
country_code: z.string().optional(),
|
||||||
|
latitude: z.number(),
|
||||||
|
longitude: z.number(),
|
||||||
|
population: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchCityInput = z.object({
|
||||||
|
query: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchCityOutput = z.object({
|
||||||
|
results: z.array(citySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const locationSchemas = {
|
||||||
|
searchCity: {
|
||||||
|
input: searchCityInput,
|
||||||
|
output: searchCityOutput,
|
||||||
|
},
|
||||||
|
};
|
||||||
5
packages/validation/src/widgets/index.ts
Normal file
5
packages/validation/src/widgets/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { weatherWidgetSchemas } from "./weather";
|
||||||
|
|
||||||
|
export const widgetSchemas = {
|
||||||
|
weather: weatherWidgetSchemas,
|
||||||
|
};
|
||||||
24
packages/validation/src/widgets/weather.ts
Normal file
24
packages/validation/src/widgets/weather.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const atLocationInput = z.object({
|
||||||
|
longitude: z.number(),
|
||||||
|
latitude: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const atLocationOutput = z.object({
|
||||||
|
current_weather: z.object({
|
||||||
|
weathercode: z.number(),
|
||||||
|
temperature: z.number(),
|
||||||
|
}),
|
||||||
|
daily: z.object({
|
||||||
|
time: z.array(z.string()),
|
||||||
|
weathercode: z.array(z.number()),
|
||||||
|
temperature_2m_max: z.array(z.number()),
|
||||||
|
temperature_2m_min: z.array(z.number()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const weatherWidgetSchemas = {
|
||||||
|
atLocationInput,
|
||||||
|
atLocationOutput,
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { WidgetOptionType } from "../options";
|
import type { WidgetOptionType } from "../options";
|
||||||
import { WidgetAppInput } from "./widget-app-input";
|
import { WidgetAppInput } from "./widget-app-input";
|
||||||
|
import { WidgetLocationInput } from "./widget-location-input";
|
||||||
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
||||||
import { WidgetNumberInput } from "./widget-number-input";
|
import { WidgetNumberInput } from "./widget-number-input";
|
||||||
import { WidgetSelectInput } from "./widget-select-input";
|
import { WidgetSelectInput } from "./widget-select-input";
|
||||||
@@ -9,7 +10,7 @@ import { WidgetTextInput } from "./widget-text-input";
|
|||||||
|
|
||||||
const mapping = {
|
const mapping = {
|
||||||
text: WidgetTextInput,
|
text: WidgetTextInput,
|
||||||
location: () => null,
|
location: WidgetLocationInput,
|
||||||
multiSelect: WidgetMultiSelectInput,
|
multiSelect: WidgetMultiSelectInput,
|
||||||
multiText: () => null,
|
multiText: () => null,
|
||||||
number: WidgetNumberInput,
|
number: WidgetNumberInput,
|
||||||
|
|||||||
280
packages/widgets/src/_inputs/widget-location-input.tsx
Normal file
280
packages/widgets/src/_inputs/widget-location-input.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
IconClick,
|
||||||
|
IconListSearch,
|
||||||
|
Loader,
|
||||||
|
NumberInput,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
|
} from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { OptionLocation } from "../options";
|
||||||
|
import type { CommonWidgetInputProps } from "./common";
|
||||||
|
import { useWidgetInputTranslation } from "./common";
|
||||||
|
import { useFormContext } from "./form";
|
||||||
|
|
||||||
|
export const WidgetLocationInput = ({
|
||||||
|
property,
|
||||||
|
kind,
|
||||||
|
}: CommonWidgetInputProps<"location">) => {
|
||||||
|
const t = useWidgetInputTranslation(kind, property);
|
||||||
|
const tLocation = useScopedI18n("widget.common.location");
|
||||||
|
const form = useFormContext();
|
||||||
|
const { openModal } = useModalAction(LocationSearchModal);
|
||||||
|
const value = form.values.options[property] as OptionLocation;
|
||||||
|
const selectionEnabled = value.name.length > 1;
|
||||||
|
|
||||||
|
const handleChange = form.getInputProps(`options.${property}`)
|
||||||
|
.onChange as LocationOnChange;
|
||||||
|
const unknownLocation = tLocation("unknownLocation");
|
||||||
|
|
||||||
|
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
handleChange({
|
||||||
|
name: event.currentTarget.value,
|
||||||
|
longitude: "",
|
||||||
|
latitude: "",
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onLocationSelect = useCallback(
|
||||||
|
(location: OptionLocation) => {
|
||||||
|
handleChange(location);
|
||||||
|
},
|
||||||
|
[handleChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSearch = useCallback(() => {
|
||||||
|
if (!selectionEnabled) return;
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
query: value.name,
|
||||||
|
onLocationSelect,
|
||||||
|
});
|
||||||
|
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
|
||||||
|
|
||||||
|
const onLatitudeChange = useCallback(
|
||||||
|
(inputValue: number | string) => {
|
||||||
|
if (typeof inputValue !== "number") return;
|
||||||
|
handleChange({
|
||||||
|
...value,
|
||||||
|
name: unknownLocation,
|
||||||
|
latitude: inputValue,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onLongitudeChange = useCallback(
|
||||||
|
(inputValue: number | string) => {
|
||||||
|
if (typeof inputValue !== "number") return;
|
||||||
|
handleChange({
|
||||||
|
...value,
|
||||||
|
name: unknownLocation,
|
||||||
|
longitude: inputValue,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fieldset legend={t("label")}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group wrap="nowrap" align="end">
|
||||||
|
<TextInput
|
||||||
|
w="100%"
|
||||||
|
label={tLocation("query")}
|
||||||
|
value={value.name}
|
||||||
|
onChange={onQueryChange}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
hidden={selectionEnabled}
|
||||||
|
label={tLocation("disabledTooltip")}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
disabled={!selectionEnabled}
|
||||||
|
onClick={onSearch}
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconListSearch size={16} />}
|
||||||
|
>
|
||||||
|
{tLocation("search")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
value={value.latitude}
|
||||||
|
onChange={onLatitudeChange}
|
||||||
|
decimalScale={5}
|
||||||
|
label={tLocation("latitude")}
|
||||||
|
hideControls
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
value={value.longitude}
|
||||||
|
onChange={onLongitudeChange}
|
||||||
|
decimalScale={5}
|
||||||
|
label={tLocation("longitude")}
|
||||||
|
hideControls
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocationOnChange = (
|
||||||
|
location: Pick<OptionLocation, "name"> & {
|
||||||
|
latitude: OptionLocation["latitude"] | "";
|
||||||
|
longitude: OptionLocation["longitude"] | "";
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface LocationSearchInnerProps {
|
||||||
|
query: string;
|
||||||
|
onLocationSelect: (location: OptionLocation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationSearchModal = createModal<LocationSearchInnerProps>(
|
||||||
|
({ actions, innerProps }) => {
|
||||||
|
const t = useScopedI18n("widget.common.location.table");
|
||||||
|
const tCommon = useScopedI18n("common");
|
||||||
|
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
|
||||||
|
query: innerProps.query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
|
||||||
|
<Table.Th style={{ width: "50%" }}>
|
||||||
|
{t("header.country")}
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>{t("header.coordinates")}</Table.Th>
|
||||||
|
<Table.Th>{t("header.population")}</Table.Th>
|
||||||
|
<Table.Th style={{ width: 40 }} />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{isPending && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Group justify="center">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{data?.results.map((city) => (
|
||||||
|
<LocationSelectTableRow
|
||||||
|
key={city.id}
|
||||||
|
city={city}
|
||||||
|
onLocationSelect={innerProps.onLocationSelect}
|
||||||
|
closeModal={actions.closeModal}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
<Group justify="right">
|
||||||
|
<Button variant="light" onClick={actions.closeModal}>
|
||||||
|
{tCommon("action.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("widget.common.location.search");
|
||||||
|
},
|
||||||
|
size: "xl",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LocationSearchTableRowProps {
|
||||||
|
city: RouterOutputs["location"]["searchCity"]["results"][number];
|
||||||
|
onLocationSelect: (location: OptionLocation) => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationSelectTableRow = ({
|
||||||
|
city,
|
||||||
|
onLocationSelect,
|
||||||
|
closeModal,
|
||||||
|
}: LocationSearchTableRowProps) => {
|
||||||
|
const t = useScopedI18n("widget.common.location.table");
|
||||||
|
const onSelect = useCallback(() => {
|
||||||
|
onLocationSelect({
|
||||||
|
name: city.name,
|
||||||
|
latitude: city.latitude,
|
||||||
|
longitude: city.longitude,
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
}, [city, onLocationSelect, closeModal]);
|
||||||
|
|
||||||
|
const formatter = Intl.NumberFormat("en", { notation: "compact" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Anchor
|
||||||
|
target="_blank"
|
||||||
|
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
|
||||||
|
>
|
||||||
|
<Text style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{city.latitude}, {city.longitude}
|
||||||
|
</Text>
|
||||||
|
</Anchor>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{city.population ? (
|
||||||
|
<Text style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatter.format(city.population)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text c="gray"> {t("population.fallback")}</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Tooltip
|
||||||
|
label={t("action.select", {
|
||||||
|
city: city.name,
|
||||||
|
countryCode: city.country_code,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
|
||||||
|
<IconClick size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,10 +16,11 @@ export const WidgetSliderInput = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InputWrapper
|
<InputWrapper
|
||||||
|
label={t("label")}
|
||||||
description={options.withDescription ? t("description") : undefined}
|
description={options.withDescription ? t("description") : undefined}
|
||||||
|
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||||
>
|
>
|
||||||
<Slider
|
<Slider
|
||||||
label={t("label")}
|
|
||||||
min={options.validate.minValue ?? undefined}
|
min={options.validate.minValue ?? undefined}
|
||||||
max={options.validate.maxValue ?? undefined}
|
max={options.validate.maxValue ?? undefined}
|
||||||
step={options.step}
|
step={options.step}
|
||||||
|
|||||||
@@ -88,4 +88,6 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
).withOptions({});
|
).withOptions({
|
||||||
|
keepMounted: true,
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ interface SliderInput extends CommonInput<number> {
|
|||||||
step?: number;
|
step?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OptLocation {
|
export interface OptionLocation {
|
||||||
name: string;
|
name: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -90,7 +90,7 @@ const optionsFactory = {
|
|||||||
withDescription: input.withDescription ?? false,
|
withDescription: input.withDescription ?? false,
|
||||||
validate: input.validate,
|
validate: input.validate,
|
||||||
}),
|
}),
|
||||||
location: (input?: CommonInput<OptLocation>) => ({
|
location: (input?: CommonInput<OptionLocation>) => ({
|
||||||
type: "location" as const,
|
type: "location" as const,
|
||||||
defaultValue: input?.defaultValue ?? {
|
defaultValue: input?.defaultValue ?? {
|
||||||
name: "",
|
name: "",
|
||||||
|
|||||||
@@ -1,7 +1,209 @@
|
|||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
IconArrowDownRight,
|
||||||
|
IconArrowUpRight,
|
||||||
|
IconMapPin,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { WeatherIcon } from "./icon";
|
||||||
|
|
||||||
export default function WeatherWidget({
|
export default function WeatherWidget({
|
||||||
options: _options,
|
options,
|
||||||
|
width,
|
||||||
}: WidgetComponentProps<"weather">) {
|
}: WidgetComponentProps<"weather">) {
|
||||||
return <div>WEATHER</div>;
|
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
latitude: options.location.latitude,
|
||||||
|
longitude: options.location.longitude,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack w="100%" h="100%" justify="space-around" gap={0} align="center">
|
||||||
|
<WeeklyForecast
|
||||||
|
weather={weather}
|
||||||
|
width={width}
|
||||||
|
options={options}
|
||||||
|
shouldHide={!options.hasForecast}
|
||||||
|
/>
|
||||||
|
<DailyWeather
|
||||||
|
weather={weather}
|
||||||
|
width={width}
|
||||||
|
options={options}
|
||||||
|
shouldHide={options.hasForecast}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DailyWeatherProps
|
||||||
|
extends Pick<WidgetComponentProps<"weather">, "width" | "options"> {
|
||||||
|
shouldHide: boolean;
|
||||||
|
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyWeather = ({
|
||||||
|
shouldHide,
|
||||||
|
width,
|
||||||
|
options,
|
||||||
|
weather,
|
||||||
|
}: DailyWeatherProps) => {
|
||||||
|
if (shouldHide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap={width < 120 ? "0.25rem" : "xs"}
|
||||||
|
justify={"center"}
|
||||||
|
direction={width < 200 ? "column" : "row"}
|
||||||
|
>
|
||||||
|
<WeatherIcon
|
||||||
|
size={width < 300 ? 30 : 50}
|
||||||
|
code={weather.current_weather.weathercode}
|
||||||
|
/>
|
||||||
|
<Title order={2}>
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.current_weather.temperature,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
)}
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{width > 200 && (
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<IconArrowUpRight />
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.daily.temperature_2m_max[0]!,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
)}
|
||||||
|
<IconArrowDownRight />
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.daily.temperature_2m_min[0]!,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{options.showCity && (
|
||||||
|
<Group wrap="nowrap" gap={4} align="center">
|
||||||
|
<IconMapPin height={15} width={15} />
|
||||||
|
<Text style={{ whiteSpace: "nowrap" }}>{options.location.name}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WeeklyForecastProps
|
||||||
|
extends Pick<WidgetComponentProps<"weather">, "width" | "options"> {
|
||||||
|
shouldHide: boolean;
|
||||||
|
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeeklyForecast = ({
|
||||||
|
shouldHide,
|
||||||
|
width,
|
||||||
|
options,
|
||||||
|
weather,
|
||||||
|
}: WeeklyForecastProps) => {
|
||||||
|
if (shouldHide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap={width < 120 ? "0.25rem" : "xs"}
|
||||||
|
justify="center"
|
||||||
|
direction="row"
|
||||||
|
>
|
||||||
|
{options.showCity && (
|
||||||
|
<Group wrap="nowrap" gap="xs" align="center">
|
||||||
|
<IconMapPin color="blue" size={30} />
|
||||||
|
<Text size="xl" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{options.location.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<WeatherIcon
|
||||||
|
size={width < 300 ? 30 : 50}
|
||||||
|
code={weather.current_weather.weathercode}
|
||||||
|
/>
|
||||||
|
<Title
|
||||||
|
order={2}
|
||||||
|
c={weather.current_weather.temperature > 20 ? "red" : "blue"}
|
||||||
|
>
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.current_weather.temperature,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
)}
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
<Forecast weather={weather} options={options} width={width} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ForecastProps
|
||||||
|
extends Pick<WidgetComponentProps<"weather">, "options" | "width"> {
|
||||||
|
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Forecast({ weather, options, width }: ForecastProps) {
|
||||||
|
return (
|
||||||
|
<Flex align="center" direction="row" justify="space-between" w="100%">
|
||||||
|
{weather.daily.time
|
||||||
|
.slice(
|
||||||
|
0,
|
||||||
|
Math.min(options.forecastDayCount, width / (width < 300 ? 64 : 92)),
|
||||||
|
)
|
||||||
|
.map((time, index) => (
|
||||||
|
<Card key={time}>
|
||||||
|
<Flex direction="column" align="center">
|
||||||
|
<Text fw={700} lh="1.25rem">
|
||||||
|
{new Date(time).getDate().toString().padStart(2, "0")}
|
||||||
|
</Text>
|
||||||
|
<WeatherIcon
|
||||||
|
size={width < 300 ? 20 : 50}
|
||||||
|
code={weather.daily.weathercode[index]!}
|
||||||
|
/>
|
||||||
|
<Text fz={width < 300 ? "xs" : "sm"} lh="1rem">
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.daily.temperature_2m_max[index]!,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text fz={width < 300 ? "xs" : "sm"} lh="1rem" c="grey">
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.daily.temperature_2m_min[index]!,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPreferredUnit = (value: number, isFahrenheit = false): string =>
|
||||||
|
isFahrenheit
|
||||||
|
? `${(value * (9 / 5) + 32).toFixed(1)}°F`
|
||||||
|
: `${value.toFixed(1)}°C`;
|
||||||
|
|||||||
81
packages/widgets/src/weather/icon.tsx
Normal file
81
packages/widgets/src/weather/icon.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
IconCloud,
|
||||||
|
IconCloudFog,
|
||||||
|
IconCloudRain,
|
||||||
|
IconCloudSnow,
|
||||||
|
IconCloudStorm,
|
||||||
|
IconQuestionMark,
|
||||||
|
IconSnowflake,
|
||||||
|
IconSun,
|
||||||
|
Tooltip,
|
||||||
|
} from "@homarr/ui";
|
||||||
|
|
||||||
|
interface WeatherIconProps {
|
||||||
|
code: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon which should be displayed when specific code is defined
|
||||||
|
* @param code weather code from api
|
||||||
|
* @returns weather tile component
|
||||||
|
*/
|
||||||
|
export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => {
|
||||||
|
const t = useScopedI18n("widget.weather");
|
||||||
|
|
||||||
|
const { icon: Icon, name } =
|
||||||
|
weatherDefinitions.find((definition) => definition.codes.includes(code)) ??
|
||||||
|
unknownWeather;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip withinPortal withArrow label={t(`kind.${name}`)}>
|
||||||
|
<Box>
|
||||||
|
<Icon style={{ float: "left" }} size={size} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WeatherDefinitionType {
|
||||||
|
icon: TablerIcon;
|
||||||
|
name: keyof TranslationObject["widget"]["weather"]["kind"];
|
||||||
|
codes: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 Clear sky
|
||||||
|
// 1, 2, 3 Mainly clear, partly cloudy, and overcast
|
||||||
|
// 45, 48 Fog and depositing rime fog
|
||||||
|
// 51, 53, 55 Drizzle: Light, moderate, and dense intensity
|
||||||
|
// 56, 57 Freezing Drizzle: Light and dense intensity
|
||||||
|
// 61, 63, 65 Rain: Slight, moderate and heavy intensity
|
||||||
|
// 66, 67 Freezing Rain: Light and heavy intensity
|
||||||
|
// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
|
||||||
|
// 77 Snow grains
|
||||||
|
// 80, 81, 82 Rain showers: Slight, moderate, and violent
|
||||||
|
// 85, 86Snow showers slight and heavy
|
||||||
|
// 95 *Thunderstorm: Slight or moderate
|
||||||
|
// 96, 99 *Thunderstorm with slight and heavy hail
|
||||||
|
const weatherDefinitions: WeatherDefinitionType[] = [
|
||||||
|
{ icon: IconSun, name: "clear", codes: [0] },
|
||||||
|
{ icon: IconCloud, name: "mainlyClear", codes: [1, 2, 3] },
|
||||||
|
{ icon: IconCloudFog, name: "fog", codes: [45, 48] },
|
||||||
|
{ icon: IconCloud, name: "drizzle", codes: [51, 53, 55] },
|
||||||
|
{ icon: IconSnowflake, name: "freezingDrizzle", codes: [56, 57] },
|
||||||
|
{ icon: IconCloudRain, name: "rain", codes: [61, 63, 65] },
|
||||||
|
{ icon: IconCloudRain, name: "freezingRain", codes: [66, 67] },
|
||||||
|
{ icon: IconCloudSnow, name: "snowFall", codes: [71, 73, 75] },
|
||||||
|
{ icon: IconCloudSnow, name: "snowGrains", codes: [77] },
|
||||||
|
{ icon: IconCloudRain, name: "rainShowers", codes: [80, 81, 82] },
|
||||||
|
{ icon: IconCloudSnow, name: "snowShowers", codes: [85, 86] },
|
||||||
|
{ icon: IconCloudStorm, name: "thunderstorm", codes: [95] },
|
||||||
|
{ icon: IconCloudStorm, name: "thunderstormWithHail", codes: [96, 99] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const unknownWeather: Omit<WeatherDefinitionType, "codes"> = {
|
||||||
|
icon: IconQuestionMark,
|
||||||
|
name: "unknown",
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IconCloud } from "@homarr/ui";
|
import { IconCloud } from "@homarr/ui";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
@@ -7,9 +8,32 @@ export const { definition, componentLoader } = createWidgetDefinition(
|
|||||||
"weather",
|
"weather",
|
||||||
{
|
{
|
||||||
icon: IconCloud,
|
icon: IconCloud,
|
||||||
options: optionsBuilder.from((factory) => ({
|
options: optionsBuilder.from(
|
||||||
location: factory.location(),
|
(factory) => ({
|
||||||
showCity: factory.switch(),
|
isFormatFahrenheit: factory.switch(),
|
||||||
})),
|
location: factory.location({
|
||||||
|
defaultValue: {
|
||||||
|
name: "Paris",
|
||||||
|
latitude: 48.85341,
|
||||||
|
longitude: 2.3488,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
showCity: factory.switch(),
|
||||||
|
hasForecast: factory.switch(),
|
||||||
|
forecastDayCount: factory.slider({
|
||||||
|
defaultValue: 5,
|
||||||
|
validate: z.number().min(1).max(7),
|
||||||
|
step: 1,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
forecastDayCount: {
|
||||||
|
shouldHide({ hasForecast }) {
|
||||||
|
return !hasForecast;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
).withDynamicImport(() => import("./component"));
|
).withDynamicImport(() => import("./component"));
|
||||||
|
|||||||
Reference in New Issue
Block a user