Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef3f42a0b | ||
|
|
319a083c6d | ||
|
|
8e7fa782b3 | ||
|
|
97652802f0 | ||
|
|
1706289124 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 'Immich Album Carousel'
|
title: 'Immich Album Carousel'
|
||||||
description: 'Shows a slideshow of your album pictures from Immich'
|
description: 'Shows a slideshow of pictures from an Immich album or your full library'
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
|
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
|
||||||
|
|
||||||
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
|
|||||||
export const immichAlbumCarouselWidget: WidgetDefinition = {
|
export const immichAlbumCarouselWidget: WidgetDefinition = {
|
||||||
icon: IconPhoto,
|
icon: IconPhoto,
|
||||||
name: "Immich Album",
|
name: "Immich Album",
|
||||||
description: "Shows a slideshow of your album pictures from Immich",
|
description: "Shows a slideshow of pictures from an Immich album or your full library",
|
||||||
path: "../../widgets/immich-album-carousel",
|
path: "../../widgets/immich-album-carousel",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Album",
|
name: "Album",
|
||||||
description: "Select the Immich album to show in the slideshow.",
|
description:
|
||||||
|
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
|
||||||
values: { type: "string" },
|
values: { type: "string" },
|
||||||
defaultValue: "",
|
defaultValue: "All photos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Interval in seconds",
|
name: "Interval in seconds",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
|
export const createQueryCacheErrorCleanup = (queryClient: QueryClient): (() => void) => {
|
||||||
|
const wasSuccessful = new Set<string>();
|
||||||
|
|
||||||
|
return queryClient.getQueryCache().subscribe((event) => {
|
||||||
|
const query = event.query;
|
||||||
|
const key = query.queryHash;
|
||||||
|
|
||||||
|
if (event.type === "removed") {
|
||||||
|
wasSuccessful.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== "updated") return;
|
||||||
|
|
||||||
|
if (query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey)) {
|
||||||
|
wasSuccessful.add(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.state.status === "error" && wasSuccessful.has(key)) {
|
||||||
|
queryClient.resetQueries({ queryKey: query.queryKey, exact: true });
|
||||||
|
wasSuccessful.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
@@ -36,6 +36,7 @@ import { env } from "@homarr/common/env";
|
|||||||
import { showWarningNotification } from "@homarr/notifications";
|
import { showWarningNotification } from "@homarr/notifications";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
|
import { createQueryCacheErrorCleanup } from "./query-cache-error-cleanup";
|
||||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
@@ -102,6 +103,10 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return createQueryCacheErrorCleanup(queryClient);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
const [trpcClient] = useState(() => {
|
const [trpcClient] = useState(() => {
|
||||||
return clientApi.createClient({
|
return clientApi.createClient({
|
||||||
links: [
|
links: [
|
||||||
@@ -162,6 +167,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
|
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
@@ -112,7 +113,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
removePersistedWidgetQueries(queryClient);
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
|
import { createQueryCacheErrorCleanup } from "~/app/[locale]/_client-providers/query-cache-error-cleanup";
|
||||||
|
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
|
describe("removePersistedWidgetQueries", () => {
|
||||||
|
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||||
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
|
removePersistedWidgetQueries(queryClient);
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
|
await expect(
|
||||||
|
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||||
|
).resolves.toBe("fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes persisted queries outside the widget router", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||||
|
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||||
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(dockerQueryKey, "stale");
|
||||||
|
queryClient.setQueryData(appQueryKey, "stale");
|
||||||
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
|
removePersistedWidgetQueries(queryClient);
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||||
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
|
removePersistedWidgetQueries(queryClient);
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("query cache error cleanup", () => {
|
||||||
|
test("resets data when a persistable query transitions from success to error", async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
||||||
|
|
||||||
|
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||||
|
const userKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(widgetKey, { old: true });
|
||||||
|
queryClient.setQueryData(userKey, { user: true });
|
||||||
|
|
||||||
|
await queryClient
|
||||||
|
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
|
||||||
|
expect(queryClient.getQueryData(userKey)).toEqual({ user: true });
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves stale data for non-persistable query errors", async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
||||||
|
|
||||||
|
const boardKey = [["board", "getBoardByName"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(boardKey, { boards: [] });
|
||||||
|
|
||||||
|
await queryClient
|
||||||
|
.fetchQuery({ queryKey: boardKey, queryFn: () => Promise.reject(new Error("API down")) })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(boardKey)).toEqual({ boards: [] });
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles success→error→success→error cycling without getting stuck", async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
||||||
|
|
||||||
|
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(widgetKey, "first-success");
|
||||||
|
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("second-success") });
|
||||||
|
expect(queryClient.getQueryData(widgetKey)).toBe("second-success");
|
||||||
|
|
||||||
|
await queryClient
|
||||||
|
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom")) })
|
||||||
|
.catch(() => {});
|
||||||
|
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
|
||||||
|
|
||||||
|
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("third-success") });
|
||||||
|
expect(queryClient.getQueryData(widgetKey)).toBe("third-success");
|
||||||
|
|
||||||
|
await queryClient
|
||||||
|
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom-2")) })
|
||||||
|
.catch(() => {});
|
||||||
|
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unsubscribe stops processing further events", async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
||||||
|
|
||||||
|
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||||
|
queryClient.setQueryData(widgetKey, "stale");
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
await queryClient
|
||||||
|
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(widgetKey)).toBe("stale");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rehydration marks persistable data as stale", () => {
|
||||||
|
test("rehydrated queries are invalidated so they refetch on mount", async () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: 0 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||||
|
const userKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
|
queryClient.setQueryData(widgetKey, { rehydrated: true });
|
||||||
|
queryClient.setQueryData(userKey, { rehydrated: true });
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||||
|
|
||||||
|
expect(queryClient.getQueryState(widgetKey)?.isInvalidated).toBe(true);
|
||||||
|
expect(queryClient.getQueryState(userKey)?.isInvalidated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
|
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||||
|
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
|
|||||||
.concat(createImmichIntegrationMiddleware("query"))
|
.concat(createImmichIntegrationMiddleware("query"))
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
albumId: z.string(),
|
albumId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
AssetMediaSize,
|
AssetMediaSize,
|
||||||
|
AssetTypeEnum,
|
||||||
|
AssetVisibility,
|
||||||
getAlbumInfo,
|
getAlbumInfo,
|
||||||
getAllAlbums,
|
getAllAlbums,
|
||||||
getMyUser,
|
getMyUser,
|
||||||
getServerStatistics,
|
getServerStatistics,
|
||||||
init,
|
init,
|
||||||
searchAssets,
|
searchAssets,
|
||||||
|
searchRandom,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
} from "@immich/sdk";
|
} from "@immich/sdk";
|
||||||
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
|
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
|
||||||
@@ -43,6 +46,7 @@ export interface ImmichAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logger = createLogger({ module: "immich-integration" });
|
const logger = createLogger({ module: "immich-integration" });
|
||||||
|
const randomPhotoCount = 50;
|
||||||
|
|
||||||
export class ImmichIntegration extends Integration {
|
export class ImmichIntegration extends Integration {
|
||||||
constructor(integration: IntegrationInput) {
|
constructor(integration: IntegrationInput) {
|
||||||
@@ -63,38 +67,36 @@ export class ImmichIntegration extends Integration {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
|
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
|
||||||
this.initClient();
|
this.initClient();
|
||||||
const requestOptions = this.getRequestOptions();
|
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([
|
const [album, albumAssets] = await Promise.all([
|
||||||
getAlbumInfo({ id: albumId }, requestOptions),
|
getAlbumInfo({ id: albumId }, requestOptions),
|
||||||
this.searchAlbumAssetsAsync(albumId),
|
this.searchAlbumAssetsAsync(albumId),
|
||||||
]);
|
]);
|
||||||
const imageProxy = new ImageProxy();
|
|
||||||
return {
|
return {
|
||||||
albumName: album.albumName,
|
albumName: album.albumName,
|
||||||
assets: await Promise.all(
|
assets: await this.createAssetsAsync(albumAssets),
|
||||||
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,
|
id: album.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -136,6 +138,32 @@ export class ImmichIntegration extends Integration {
|
|||||||
return assets;
|
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) {
|
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
|
||||||
return {
|
return {
|
||||||
// Undici and node types are not the same
|
// Undici and node types are not the same
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
|||||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||||
ImmichAlbum,
|
ImmichAlbum,
|
||||||
IntegrationKindByCategory<"photoService">,
|
IntegrationKindByCategory<"photoService">,
|
||||||
{ albumId: string }
|
{ albumId?: string }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
|||||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||||
const res = await withTimeoutAsync(async (signal) => {
|
const res = await withTimeoutAsync(async (signal) => {
|
||||||
return await fetchWithTrustedCertificatesAsync(
|
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¤t_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,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
|||||||
sunset: weather.daily.sunset[index],
|
sunset: weather.daily.sunset[index],
|
||||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||||
|
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
} satisfies Weather;
|
} satisfies Weather;
|
||||||
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
|
|||||||
sunset: z.array(z.string()),
|
sunset: z.array(z.string()),
|
||||||
wind_speed_10m_max: z.array(z.number()),
|
wind_speed_10m_max: z.array(z.number()),
|
||||||
wind_gusts_10m_max: z.array(z.number()),
|
wind_gusts_10m_max: z.array(z.number()),
|
||||||
|
relative_humidity_2m_mean: z.array(z.number()),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,5 +68,6 @@ export interface Weather {
|
|||||||
sunset: string | undefined;
|
sunset: string | undefined;
|
||||||
maxWindSpeed: number | undefined;
|
maxWindSpeed: number | undefined;
|
||||||
maxWindGusts: number | undefined;
|
maxWindGusts: number | undefined;
|
||||||
|
humidity: number | undefined;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
@@ -2257,7 +2257,8 @@
|
|||||||
"sunrise": "Sunrise",
|
"sunrise": "Sunrise",
|
||||||
"sunset": "Sunset",
|
"sunset": "Sunset",
|
||||||
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
|
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
|
||||||
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
|
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
|
||||||
|
"humidity": "Humidity: {humidity}%"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
@@ -3006,10 +3007,11 @@
|
|||||||
},
|
},
|
||||||
"immich-albumCarousel": {
|
"immich-albumCarousel": {
|
||||||
"name": "Immich Album",
|
"name": "Immich Album",
|
||||||
|
"allPhotos": "All photos",
|
||||||
"option": {
|
"option": {
|
||||||
"albumId": {
|
"albumId": {
|
||||||
"label": "Album",
|
"label": "Album",
|
||||||
"description": "Select the Immich album to show in the slideshow."
|
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library."
|
||||||
},
|
},
|
||||||
"rotationIntervalSeconds": {
|
"rotationIntervalSeconds": {
|
||||||
"label": "Interval in seconds",
|
"label": "Interval in seconds",
|
||||||
@@ -3024,9 +3026,8 @@
|
|||||||
"description": "Show photos in a random order instead of always starting with the same album order."
|
"description": "Show photos in a random order instead of always starting with the same album order."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Slideshow of pictures in your Immich album",
|
"description": "Slideshow of pictures from an Immich album or your full library",
|
||||||
"noAlbumSelected": "No album selected, edit widget and choose an album",
|
"noPhotos": "No photos found"
|
||||||
"noPhotos": "No photos in this album"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"name": "Download Client",
|
"name": "Download Client",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
|
import { useCallback, useState } from "react";
|
||||||
|
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
@@ -11,9 +13,14 @@ interface CalendarDayProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
rootWidth: number;
|
rootWidth: number;
|
||||||
rootHeight: number;
|
rootHeight: number;
|
||||||
|
onOpen: (close: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
|
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [pinned, setPinned] = useState(false);
|
||||||
|
const { primaryColor } = useMantineTheme();
|
||||||
|
const { ref, height } = useElementSize();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const mantineTheme = useMantineTheme();
|
const mantineTheme = useMantineTheme();
|
||||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||||
@@ -22,19 +29,39 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
|||||||
const shouldScaleDown = minAxisSize < 350;
|
const shouldScaleDown = minAxisSize < 350;
|
||||||
const isSmall = rootHeight < 256;
|
const isSmall = rootHeight < 256;
|
||||||
|
|
||||||
|
const isTooSmallForIndicators = height < 30;
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
onOpen(() => {
|
||||||
|
setOpened(false);
|
||||||
|
setPinned(false);
|
||||||
|
});
|
||||||
|
setOpened(true);
|
||||||
|
}, [disabled, onOpen]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (!pinned) setOpened(false);
|
||||||
|
}, [pinned]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard
|
<Popover
|
||||||
position="bottom"
|
position="bottom"
|
||||||
withArrow
|
withArrow
|
||||||
withinPortal
|
withinPortal
|
||||||
radius="lg"
|
radius="lg"
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
transitionProps={{ transition: "pop" }}
|
transitionProps={{
|
||||||
openDelay={350}
|
transition: "pop",
|
||||||
closeDelay={400}
|
}}
|
||||||
|
onChange={(value) => {
|
||||||
|
setOpened(value);
|
||||||
|
if (!value) setPinned(false);
|
||||||
|
}}
|
||||||
|
opened={opened}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<HoverCard.Target>
|
<Popover.Target>
|
||||||
<Container
|
<Container
|
||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
@@ -42,23 +69,38 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
|||||||
pt={isSmall ? 0 : 10}
|
pt={isSmall ? 0 : 10}
|
||||||
pb={isSmall ? 0 : 10}
|
pb={isSmall ? 0 : 10}
|
||||||
m={0}
|
m={0}
|
||||||
pos="relative"
|
ref={ref}
|
||||||
|
bd={`2px solid ${opened && !disabled ? primaryColor : "transparent"}`}
|
||||||
style={{
|
style={{
|
||||||
alignContent: "center",
|
alignContent: "center",
|
||||||
borderRadius: actualItemRadius,
|
borderRadius: actualItemRadius,
|
||||||
cursor: disabled ? "default" : "pointer",
|
cursor: disabled ? "default" : "pointer",
|
||||||
}}
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) setPinned((prev) => !prev);
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</Text>
|
</Text>
|
||||||
<NotificationIndicator events={events} isSmall={isSmall} />
|
{!isTooSmallForIndicators && <NotificationIndicator events={events} isSmall={isSmall} />}
|
||||||
</Container>
|
</Container>
|
||||||
</HoverCard.Target>
|
</Popover.Target>
|
||||||
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
|
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
|
||||||
|
<Popover.Dropdown
|
||||||
|
maw="calc(100vw - 24px)"
|
||||||
|
w={512}
|
||||||
|
pe={4}
|
||||||
|
pb={0}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
onMouseEnter={() => setOpened(true)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<CalendarEventList events={events} />
|
<CalendarEventList events={events} />
|
||||||
</HoverCard.Dropdown>
|
</Popover.Dropdown>
|
||||||
</HoverCard>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +111,7 @@ interface NotificationIndicatorProps {
|
|||||||
|
|
||||||
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
|
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
|
||||||
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
|
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
|
||||||
|
/* position bottom is lower when small to not be on top of number*/
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
w="75%"
|
w="75%"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useMantineTheme } from "@mantine/core";
|
import { useMantineTheme } from "@mantine/core";
|
||||||
import { Calendar } from "@mantine/dates";
|
import { Calendar } from "@mantine/dates";
|
||||||
@@ -65,6 +65,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
|||||||
const isSmall = width < 256;
|
const isSmall = width < 256;
|
||||||
|
|
||||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||||
|
const activeCloseRef = useRef<(() => void) | null>(null);
|
||||||
|
const onDayOpen = useCallback((close: () => void) => {
|
||||||
|
activeCloseRef.current?.();
|
||||||
|
activeCloseRef.current = close;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -141,6 +146,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
|||||||
disabled={isEditMode || eventsForDate.length === 0}
|
disabled={isEditMode || eventsForDate.length === 0}
|
||||||
rootWidth={width}
|
rootWidth={width}
|
||||||
rootHeight={height}
|
rootHeight={height}
|
||||||
|
onOpen={onDayOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||||
|
|
||||||
export default function ImmichAlbumCarouselWidget({
|
export default function ImmichAlbumCarouselWidget({
|
||||||
integrationIds,
|
integrationIds,
|
||||||
@@ -21,9 +22,9 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
|
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
|
||||||
{
|
{
|
||||||
integrationId: integrationIds[0] ?? "",
|
integrationId: integrationIds[0] ?? "",
|
||||||
albumId: options.albumId ?? "",
|
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
|
||||||
},
|
},
|
||||||
{ enabled: Boolean(options.albumId) },
|
{ enabled: integrationIds.length > 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const photoAssets = useMemo(() => {
|
const photoAssets = useMemo(() => {
|
||||||
@@ -35,10 +36,6 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
setCurrentPhotoIndex(0);
|
setCurrentPhotoIndex(0);
|
||||||
}, [photoAssets]);
|
}, [photoAssets]);
|
||||||
|
|
||||||
if (!options.albumId) {
|
|
||||||
return <NoAlbumSelected />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!album) return <WidgetEmptyState />;
|
if (!album) return <WidgetEmptyState />;
|
||||||
|
|
||||||
if (album.assets.length === 0) {
|
if (album.assets.length === 0) {
|
||||||
@@ -117,20 +114,6 @@ 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() {
|
function NoPhotosInAlbum() {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ALL_PHOTOS_ALBUM_ID = "all";
|
||||||
@@ -2,9 +2,11 @@ import { IconPhoto } from "@tabler/icons-react";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { createWidgetDefinition } from "../../definition";
|
import { createWidgetDefinition } from "../../definition";
|
||||||
import { optionsBuilder } from "../../options";
|
import { optionsBuilder } from "../../options";
|
||||||
|
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
|
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
|
||||||
icon: IconPhoto,
|
icon: IconPhoto,
|
||||||
@@ -15,9 +17,11 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
|
|||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
albumId: factory.integrationSelect({
|
albumId: factory.integrationSelect({
|
||||||
|
defaultValue: ALL_PHOTOS_ALBUM_ID,
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
useOptions: (integrationIds: string[]) => {
|
useOptions: (integrationIds: string[]) => {
|
||||||
|
const t = useScopedI18n("widget.immich-albumCarousel");
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
isPending,
|
isPending,
|
||||||
@@ -26,7 +30,14 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
|
|||||||
{ integrationId: integrationIds[0] ?? "" },
|
{ integrationId: integrationIds[0] ?? "" },
|
||||||
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
|
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
|
||||||
);
|
);
|
||||||
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
|
return {
|
||||||
|
data: [
|
||||||
|
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
|
||||||
|
...data.map((album) => ({ value: album.id, label: album.albumName })),
|
||||||
|
],
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
rotationIntervalSeconds: factory.number({
|
rotationIntervalSeconds: factory.number({
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
|||||||
const currentlyPlaying = row.original.currentlyPlaying;
|
const currentlyPlaying = row.original.currentlyPlaying;
|
||||||
if (!currentlyPlaying) return null;
|
if (!currentlyPlaying) return null;
|
||||||
|
|
||||||
const isPaused = currentlyPlaying.playback.state === "paused";
|
const playback = currentlyPlaying.playback;
|
||||||
|
const isPaused = playback?.state === "paused";
|
||||||
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
|
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
|
||||||
|
|
||||||
const { positionMs, durationMs } = currentlyPlaying.playback;
|
const positionMs = playback?.positionMs ?? null;
|
||||||
|
const durationMs = playback?.durationMs ?? null;
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
positionMs !== null && durationMs !== null && durationMs > 0
|
positionMs !== null && durationMs !== null && durationMs > 0
|
||||||
? Math.min(100, Math.round((positionMs / durationMs) * 100))
|
? Math.min(100, Math.round((positionMs / durationMs) * 100))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||||
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
|
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
@@ -87,6 +87,12 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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 className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<IconArrowUpRight size={16} />
|
<IconArrowUpRight size={16} />
|
||||||
@@ -204,6 +210,7 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
|
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
|
||||||
maxWindSpeed={dayWeather.maxWindSpeed}
|
maxWindSpeed={dayWeather.maxWindSpeed}
|
||||||
maxWindGusts={dayWeather.maxWindGusts}
|
maxWindGusts={dayWeather.maxWindGusts}
|
||||||
|
humidity={dayWeather.humidity}
|
||||||
/>
|
/>
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IconCloudRain,
|
IconCloudRain,
|
||||||
IconCloudSnow,
|
IconCloudSnow,
|
||||||
IconCloudStorm,
|
IconCloudStorm,
|
||||||
|
IconDroplets,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconSnowflake,
|
IconSnowflake,
|
||||||
@@ -51,6 +52,7 @@ interface WeatherDescriptionProps {
|
|||||||
sunset?: string;
|
sunset?: string;
|
||||||
maxWindSpeed?: number;
|
maxWindSpeed?: number;
|
||||||
maxWindGusts?: number;
|
maxWindGusts?: number;
|
||||||
|
humidity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +80,7 @@ export const WeatherDescription = ({
|
|||||||
sunset,
|
sunset,
|
||||||
maxWindSpeed,
|
maxWindSpeed,
|
||||||
maxWindGusts,
|
maxWindGusts,
|
||||||
|
humidity,
|
||||||
}: WeatherDescriptionProps) => {
|
}: WeatherDescriptionProps) => {
|
||||||
const t = useScopedI18n("widget.weather");
|
const t = useScopedI18n("widget.weather");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
@@ -97,6 +100,9 @@ export const WeatherDescription = ({
|
|||||||
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
<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={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
||||||
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</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 && (
|
{maxWindSpeed !== undefined && (
|
||||||
<List.Item icon={<IconWind size={15} />}>
|
<List.Item icon={<IconWind size={15} />}>
|
||||||
{t("dailyForecast.maxWindSpeed", {
|
{t("dailyForecast.maxWindSpeed", {
|
||||||
|
|||||||
Reference in new issue
Block a user