feat: add immich integration and widget (#5165)
* feat: add immich integration and widget * feat: use sdk
This commit is contained in:
1 parent
6aaaf31d8b
commit
cdd90b8aa0
18 files changed
+609
No files matched your search
@@ -0,0 +1,52 @@
|
||||
.carouselContainer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--mantine-color-gray-9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.carouselImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--mantine-color-gray-8);
|
||||
}
|
||||
|
||||
.photoInfo {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
color: white;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&[data-active="true"] {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function ImmichAlbumCarouselWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
|
||||
if (!options.albumId) {
|
||||
return <NoAlbumSelected />;
|
||||
}
|
||||
|
||||
const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
albumId: options.albumId,
|
||||
});
|
||||
|
||||
if (album.assets.length === 0) {
|
||||
return <NoPhotosInAlbum />;
|
||||
}
|
||||
|
||||
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
|
||||
|
||||
if (photoAssets.length === 0) {
|
||||
return <NoPhotosInAlbum />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
assets={photoAssets}
|
||||
currentIndex={currentPhotoIndex}
|
||||
setCurrentIndex={setCurrentPhotoIndex}
|
||||
rotationInterval={options.rotationIntervalSeconds}
|
||||
showPhotoInfo={options.showPhotoInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CarouselProps {
|
||||
assets: {
|
||||
id: string;
|
||||
deviceAssetId: string;
|
||||
originalPath: string;
|
||||
fileModifiedAt: string;
|
||||
publicLink: string;
|
||||
}[];
|
||||
currentIndex: number;
|
||||
setCurrentIndex: Dispatch<SetStateAction<number>>;
|
||||
rotationInterval: number;
|
||||
showPhotoInfo: boolean;
|
||||
}
|
||||
|
||||
function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, showPhotoInfo }: CarouselProps) {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % assets.length);
|
||||
}, rotationInterval * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [assets.length, rotationInterval, setCurrentIndex]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentAsset = assets[currentIndex]!;
|
||||
|
||||
return (
|
||||
<Box w="100%" h="100%" className={classes.carouselContainer}>
|
||||
<Image src={currentAsset.publicLink} alt="Album photo" 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>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentIndex + 1} / {assets.length}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertCircle size={32} />
|
||||
<Text size="sm" fw={500}>
|
||||
{t("widget.immich-albumCarousel.noPhotos")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import z from "zod";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
|
||||
icon: IconPhoto,
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
albumId: factory.text({
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
rotationIntervalSeconds: factory.number({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(3600),
|
||||
withDescription: true,
|
||||
}),
|
||||
showPhotoInfo: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,20 @@
|
||||
.statItem {
|
||||
padding: 12px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.statItem {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function ImmichServerStatsWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
}: WidgetComponentProps<"immich-serverStats">) {
|
||||
const t = useI18n();
|
||||
const [stats] = clientApi.widget.immich.getServerStats.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%" p="md">
|
||||
{options.showUsers && (
|
||||
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
|
||||
)}
|
||||
{options.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size={20} />}
|
||||
label={t("widget.immich-serverStats.photos")}
|
||||
value={stats.photoCount}
|
||||
/>
|
||||
)}
|
||||
{options.showVideos && (
|
||||
<StatItem
|
||||
icon={<IconVideo size={20} />}
|
||||
label={t("widget.immich-serverStats.videos")}
|
||||
value={stats.videoCount}
|
||||
/>
|
||||
)}
|
||||
{options.showStorage && (
|
||||
<StatItem
|
||||
icon={<IconDatabase size={20} />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
value={humanFileSize(stats.totalLibraryUsageInBytes)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value }: StatItemProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" className={classes.statItem}>
|
||||
<Group gap="sm" align="center">
|
||||
{icon}
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="var(--mantine-primary-color)">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IconGraphFilled } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
|
||||
icon: IconGraphFilled,
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showUsers: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showPhotos: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showVideos: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showStorage: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -20,6 +20,8 @@ import * as downloads from "./downloads";
|
||||
import * as firewall from "./firewall";
|
||||
import * as healthMonitoring from "./health-monitoring";
|
||||
import * as iframe from "./iframe";
|
||||
import * as immichAlbumCarousel from "./immich/album-carousel";
|
||||
import * as immichServerStats from "./immich/server-stats";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as indexerManager from "./indexer-manager";
|
||||
import * as mediaReleases from "./media-releases";
|
||||
@@ -79,6 +81,8 @@ export const widgetImports = {
|
||||
systemResources,
|
||||
coolify,
|
||||
systemDisks,
|
||||
"immich-serverStats": immichServerStats,
|
||||
"immich-albumCarousel": immichAlbumCarousel,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
Reference in new issue
Block a user