feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -2,11 +2,19 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import {
IconAlertCircle,
IconCalendar,
IconChevronLeft,
IconChevronRight,
IconPlayerPause,
IconPlayerPlay,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
@@ -16,8 +24,16 @@ import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({
integrationIds,
options,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const persistedState = widgetStateRef?.current?.immichAlbumCarousel as
| { currentPhotoIndex?: number; paused?: boolean }
| undefined;
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(() =>
typeof persistedState?.currentPhotoIndex === "number" ? persistedState.currentPhotoIndex : 0,
);
const [paused, setPaused] = useState(persistedState?.paused === true);
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -33,8 +49,42 @@ export default function ImmichAlbumCarouselWidget({
}, [album?.assets, options.randomizePhotos]);
useEffect(() => {
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (photoAssets.length === 0) return;
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
}, [photoAssets.length]);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
immichAlbumCarousel: { currentPhotoIndex, paused },
};
}, [currentPhotoIndex, paused, widgetStateRef]);
useEffect(() => {
if (!widgetStateRef) return;
const clearActions = () => {
if (!widgetStateRef.current) return;
delete widgetStateRef.current.previousPhoto;
delete widgetStateRef.current.nextPhoto;
delete widgetStateRef.current.toggleSlideshow;
};
if (photoAssets.length <= 1) {
clearActions();
return;
}
const previousPhoto = () =>
setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
const toggleSlideshow = () => setPaused((value) => !value);
widgetStateRef.current = {
...widgetStateRef.current,
previousPhoto,
nextPhoto,
toggleSlideshow,
};
return clearActions;
}, [photoAssets.length, widgetStateRef]);
if (!album) return <WidgetEmptyState />;
@@ -53,6 +103,10 @@ export default function ImmichAlbumCarouselWidget({
setCurrentIndex={setCurrentPhotoIndex}
rotationInterval={options.rotationIntervalSeconds}
showPhotoInfo={options.showPhotoInfo}
albumName={album.albumName}
advanced={displayMode === "advanced"}
paused={paused}
setPaused={setPaused}
/>
);
}
@@ -73,7 +127,6 @@ function shuffle<T>(items: T[]) {
interface CarouselProps {
assets: {
id: string;
originalPath: string;
fileModifiedAt: string;
publicLink: string;
}[];
@@ -81,36 +134,126 @@ interface CarouselProps {
setCurrentIndex: Dispatch<SetStateAction<number>>;
rotationInterval: number;
showPhotoInfo: boolean;
albumName: string;
advanced: boolean;
paused: boolean;
setPaused: Dispatch<SetStateAction<boolean>>;
}
function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, showPhotoInfo }: CarouselProps) {
function Carousel({
assets,
currentIndex,
setCurrentIndex,
rotationInterval,
showPhotoInfo,
albumName,
advanced,
paused,
setPaused,
}: CarouselProps) {
const t = useScopedI18n("widget.immich-albumCarousel");
const reduceMotion = useReducedMotion();
useEffect(() => {
if (paused || reduceMotion || assets.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % assets.length);
}, rotationInterval * 1000);
return () => clearInterval(interval);
}, [assets.length, rotationInterval, setCurrentIndex]);
}, [assets.length, paused, reduceMotion, rotationInterval, setCurrentIndex]);
const safeCurrentIndex = Math.min(currentIndex, assets.length - 1);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const currentAsset = assets[currentIndex]!;
const currentAsset = assets[safeCurrentIndex]!;
const move = (offset: number) => setCurrentIndex((current) => (current + offset + assets.length) % assets.length);
return (
<Box w="100%" h="100%" className={classes.carouselContainer}>
<Image src={currentAsset.publicLink} alt="Album photo" className={classes.carouselImage} />
<Stack w="100%" h="100%" gap={0}>
<Box w="100%" style={{ flex: 1, minHeight: 0 }} className={classes.carouselContainer}>
<Image src={currentAsset.publicLink} alt={t("albumPhoto")} className={classes.carouselImage} />
{showPhotoInfo && (
<Stack gap="xs" className={classes.photoInfo} p="md">
<Group gap="xs">
<IconCalendar size={16} />
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
{advanced && (
<>
<ActionIcon
aria-label={t("actions.previousPhoto")}
variant="filled"
radius="xl"
pos="absolute"
left="md"
top="50%"
onClick={() => move(-1)}
>
<IconChevronLeft size={18} />
</ActionIcon>
<Group pos="absolute" right="md" top="md" gap="xs">
<ActionIcon
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
variant="filled"
radius="xl"
onClick={() => setPaused((value) => !value)}
>
{paused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
</ActionIcon>
</Group>
<ActionIcon
aria-label={t("actions.nextPhoto")}
variant="filled"
radius="xl"
pos="absolute"
right="md"
top="50%"
onClick={() => move(1)}
>
<IconChevronRight size={18} />
</ActionIcon>
</>
)}
{(showPhotoInfo || advanced) && (
<Stack gap="xs" className={classes.photoInfo} p="md">
{albumName && (
<Text size="xs" fw={600}>
{albumName}
</Text>
)}
<Group gap="xs">
<IconCalendar size={16} />
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
</Group>
<Text size="xs" c="dimmed">
{safeCurrentIndex + 1} / {assets.length}
</Text>
</Stack>
)}
</Box>
{advanced && (
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}>
<Group gap={6} wrap="nowrap">
{assets.slice(0, 24).map((asset, index) => (
<UnstyledButton
key={asset.id}
onClick={() => setCurrentIndex(index)}
aria-label={t("actions.photo", { number: index + 1 })}
aria-pressed={index === safeCurrentIndex}
aria-current={index === safeCurrentIndex ? "true" : undefined}
>
<Image
src={asset.publicLink}
alt=""
w={64}
h={44}
radius="sm"
fit="cover"
style={{ opacity: index === currentIndex ? 1 : 0.55 }}
/>
</UnstyledButton>
))}
</Group>
<Text size="xs" c="dimmed">
{currentIndex + 1} / {assets.length}
</Text>
</Stack>
</ScrollArea>
)}
</Box>
</Stack>
);
}
@@ -1,4 +1,4 @@
import { IconPhoto } from "@tabler/icons-react";
import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
@@ -14,6 +14,37 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
const action = widgetStateRef.current?.[key];
if (typeof action === "function") action();
};
const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
return [
{
key: "previousPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
icon: IconChevronLeft,
disabled: disabled("previousPhoto"),
onClick: call("previousPhoto"),
},
{
key: "nextPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
icon: IconChevronRight,
disabled: disabled("nextPhoto"),
onClick: call("nextPhoto"),
},
{
key: "toggleSlideshow",
label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
icon: IconPlayerPause,
disabled: disabled("toggleSlideshow"),
onClick: call("toggleSlideshow"),
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { Group, Stack, Text } from "@mantine/core";
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -15,16 +15,22 @@ import classes from "./component.module.css";
export default function ImmichServerStatsWidget({
integrationIds,
options,
displayMode = "compact",
width,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
if (!stats) return <WidgetEmptyState />;
return (
<Stack gap="md" h="100%" p="md">
const statsContent = (
<SimpleGrid cols={displayMode === "advanced" ? 4 : width >= 320 ? 2 : 1} spacing="sm">
{options.showUsers && (
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -49,6 +55,39 @@ export default function ImmichServerStatsWidget({
value={formatBytes(stats.totalLibraryUsageInBytes)}
/>
)}
</SimpleGrid>
);
if (displayMode === "compact") {
return (
<Stack gap="md" h="100%" p="md" justify="center">
{statsContent}
</Stack>
);
}
const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
<Stack gap="lg" h="100%" p="lg">
{statsContent}
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
{sortedAlbums.map((album) => (
<Stack key={album.id} gap={4} p="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{album.albumName}
</Text>
<Text size="xs" c="dimmed">
{album.assetCount.toLocaleString()}
</Text>
</Group>
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
</Stack>
))}
</SimpleGrid>
</ScrollArea>
</Stack>
);
}
@@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showUsers: factory.switch({