Compare commits

..
Author SHA1 Message Date
Thomas Camlong 6d204c9aaa fix: remove auto-placement of widgets for all integrations on new boards
The seed pipeline and onboarding flow auto-placed a widget for every
integration that existed in the database. This is overly aggressive
and places obscure widgets (like ArchiveTeam Warrior) on every new
dashboard. Now only explicitly listed default widgets (clock, weather,
bookmarks) and apps are placed automatically.
2026-07-20 17:04:30 +02:00
Thomas Camlong 070e186b57 fix: stop auto-seeding ArchiveTeam Warrior integration and widget on default dashboards 2026-07-20 17:00:11 +02:00
29 changed files with 82 additions and 209 deletions

No files matched your search

@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
description: 'Shows a slideshow of your album pictures from Immich'
hide_title: true
---
@@ -37,3 +37,4 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,16 +4,15 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of pictures from an Immich album or your full library",
description: "Shows a slideshow of your album pictures from Immich",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
description: "Select the Immich album to show in the slideshow.",
values: { type: "string" },
defaultValue: "All photos",
defaultValue: "",
},
{
name: "Interval in seconds",
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -33,9 +33,6 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session?.user.permissions.includes("board-modify-all"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.13.1"
"pnpm": ">=11.11.0"
},
"packageManager": "pnpm@11.13.1"
"packageManager": "pnpm@11.11.0"
}
+2 -2
View File
@@ -69,7 +69,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.13.1"
"pnpm": ">=11.11.0"
},
"packageManager": "pnpm@11.13.1"
"packageManager": "pnpm@11.11.0"
}
+1 -2
View File
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: permissionRequiredProcedure
.requiresPermission("board-modify-all")
getPaginated: protectedProcedure
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
+1 -1
View File
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
});
}
if (ctx.session?.user.permissions.includes("board-modify-all")) {
if (ctx.session?.user) {
statistics.push({
titleKey: "app",
subtitleKey: "resources",
@@ -271,7 +271,6 @@ export const onboardRouter = createTRPCRouter({
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const board = await db.query.boards.findFirst({
@@ -316,7 +315,6 @@ export const onboardRouter = createTRPCRouter({
layoutId: layout.id,
columnCount: layout.columnCount,
},
allIntegrations,
allApps,
);
}
-27
View File
@@ -76,33 +76,6 @@ describe("all should return all apps", () => {
});
});
describe("getPaginated should require board modification access", () => {
test("should throw FORBIDDEN without board-modify-all", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["board-modify-all"]),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
totalCount: 1,
items: [{ id: "1" }],
});
});
});
describe("byId should return an app by id", () => {
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
// Arrange
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string().optional(),
albumId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
-2
View File
@@ -698,13 +698,11 @@ const seedBoardWidgetsAsync = async (db: Database) => {
const layout = board.layouts[0];
if (!section || !layout) return;
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const count = await placeAllWidgetsAsync(
db,
{ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount },
allIntegrations,
allApps,
);
+8 -48
View File
@@ -1,19 +1,11 @@
import superjson from "superjson";
import { stringify } from "superjson";
import { createId } from "@homarr/common";
import {
defaultBookmarkApps,
defaultWidgetConfigs,
emptySuperJSON,
getWidgetKindsForIntegration,
} from "@homarr/definitions";
import { defaultBookmarkApps, defaultWidgetConfigs, emptySuperJSON } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import type { Database } from "..";
import { integrationItems, itemLayouts, items } from "../schema";
import type { Integration } from "../schema";
const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
interface BoardTarget {
boardId: string;
@@ -77,12 +69,7 @@ interface App {
name: string;
}
export const placeAllWidgetsAsync = async (
db: Database,
target: BoardTarget,
allIntegrations: Integration[],
allApps: App[],
) => {
export const placeAllWidgetsAsync = async (db: Database, target: BoardTarget, allApps: App[]) => {
const ctx: PlacementContext = {
...target,
xOffset: 0,
@@ -92,38 +79,11 @@ export const placeAllWidgetsAsync = async (
const placedWidgets = new Set<WidgetKind>();
for (const integration of allIntegrations) {
for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) {
if (placedWidgets.has(widgetKind)) continue;
const config = widgetConfigMap.get(widgetKind);
if (config?.skip) continue;
placedWidgets.add(widgetKind);
const matchingIds = allIntegrations
.filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind))
.map((row) => row.id);
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
db,
ctx,
widgetKind,
matchingIds,
options,
config && { width: config.width, height: config.height },
);
}
}
for (const app of allApps) {
await placeWidgetAsync(
db,
ctx,
"app",
[],
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
{ width: 1, height: 1 },
);
await placeWidgetAsync(db, ctx, "app", [], stringify({ appId: app.id, openInNewTab: true, showTitle: true }), {
width: 1,
height: 1,
});
}
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
@@ -138,7 +98,7 @@ export const placeAllWidgetsAsync = async (
options = { ...options, items: bookmarkAppIds };
}
await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
await placeWidgetAsync(db, ctx, config.kind, [], options ? stringify(options) : undefined, {
width: config.width,
height: config.height,
});
@@ -1,14 +1,11 @@
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo,
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -46,7 +43,6 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -67,36 +63,38 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await this.createAssetsAsync(albumAssets),
assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
id: album.id,
};
}
@@ -138,32 +136,6 @@ export class ImmichIntegration extends Integration {
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId?: string }
{ albumId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
@@ -16,6 +16,4 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
users: await integrationInstance.getUsersAsync(),
};
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
+1 -4
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
{ signal },
);
});
@@ -27,7 +27,6 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
@@ -49,7 +48,6 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}),
});
@@ -68,6 +66,5 @@ export interface Weather {
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
humidity: number | undefined;
}[];
}
@@ -17,10 +17,6 @@ export const appIntegrationBoardMode = {
return groups;
}
return groups.concat(
session.user.permissions.includes("board-modify-all")
? [appsSearchGroup, integrationsSearchGroup]
: [integrationsSearchGroup],
);
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
},
} satisfies SearchMode;
@@ -87,8 +87,7 @@ export const globalCommandGroup = createGroup<Command>({
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden:
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
hidden: !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconBox,
path: "/manage/apps",
name: t("manageApp.label"),
hidden: !session?.user.permissions.includes("board-modify-all"),
hidden: !session,
},
{
icon: IconPlug,
+5 -6
View File
@@ -2257,8 +2257,7 @@
"sunrise": "Sunrise",
"sunset": "Sunset",
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
"humidity": "Humidity: {humidity}%"
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
},
"kind": {
"clear": "Clear",
@@ -3007,11 +3006,10 @@
},
"immich-albumCarousel": {
"name": "Immich Album",
"allPhotos": "All photos",
"option": {
"albumId": {
"label": "Album",
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library."
"description": "Select the Immich album to show in the slideshow."
},
"rotationIntervalSeconds": {
"label": "Interval in seconds",
@@ -3026,8 +3024,9 @@
"description": "Show photos in a random order instead of always starting with the same album order."
}
},
"description": "Slideshow of pictures from an Immich album or your full library",
"noPhotos": "No photos found"
"description": "Slideshow of pictures in your Immich album",
"noAlbumSelected": "No album selected, edit widget and choose an album",
"noPhotos": "No photos in this album"
},
"downloads": {
"name": "Download Client",
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({
integrationIds,
@@ -22,9 +21,9 @@ export default function ImmichAlbumCarouselWidget({
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
integrationId: integrationIds[0] ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
albumId: options.albumId ?? "",
},
{ enabled: integrationIds.length > 0 },
{ enabled: Boolean(options.albumId) },
);
const photoAssets = useMemo(() => {
@@ -36,6 +35,10 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) {
@@ -114,6 +117,20 @@ function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, sho
);
}
function NoAlbumSelected() {
const t = useI18n();
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertCircle size={32} />
<Text size="sm" fw={500}>
{t("widget.immich-albumCarousel.noAlbumSelected")}
</Text>
</Stack>
</Center>
);
}
function NoPhotosInAlbum() {
const t = useI18n();
return (
@@ -1 +0,0 @@
export const ALL_PHOTOS_ALBUM_ID = "all";
@@ -2,11 +2,9 @@ import { IconPhoto } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
@@ -17,11 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
@@ -30,14 +26,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
},
}),
rotationIntervalSeconds: factory.number({
@@ -66,12 +66,10 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const isPaused = currentlyPlaying.playback.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const { positionMs, durationMs } = currentlyPlaying.playback;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
+1 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
@@ -87,12 +87,6 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Text>
</Group>
)}
{weather.daily[0]?.humidity !== undefined && (
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
<IconDroplets size={16} />
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
</Group>
)}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<IconArrowUpRight size={16} />
@@ -210,7 +204,6 @@ function Forecast({ weather, options }: WeatherProps) {
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
maxWindSpeed={dayWeather.maxWindSpeed}
maxWindGusts={dayWeather.maxWindGusts}
humidity={dayWeather.humidity}
/>
</HoverCard.Dropdown>
</HoverCard>
-6
View File
@@ -5,7 +5,6 @@ import {
IconCloudRain,
IconCloudSnow,
IconCloudStorm,
IconDroplets,
IconMoon,
IconQuestionMark,
IconSnowflake,
@@ -52,7 +51,6 @@ interface WeatherDescriptionProps {
sunset?: string;
maxWindSpeed?: number;
maxWindGusts?: number;
humidity?: number;
}
/**
@@ -80,7 +78,6 @@ export const WeatherDescription = ({
sunset,
maxWindSpeed,
maxWindGusts,
humidity,
}: WeatherDescriptionProps) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
@@ -100,9 +97,6 @@ export const WeatherDescription = ({
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
{humidity !== undefined && (
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
)}
{maxWindSpeed !== undefined && (
<List.Item icon={<IconWind size={15} />}>
{t("dailyForecast.maxWindSpeed", {