feat: modernize dashboard widgets
This commit is contained in:
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({
|
||||
|
||||
Reference in new issue
Block a user