merge: integrate dev branch, resolve package.json and lockfile conflicts

Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]andajnart committed 2026-03-13 09:20:05 +00:00
1 parent 1e438ec3c3
commit 6cbf72462a
117 files changed
+4495 -1648

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"));
+4
View File
@@ -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;
+2 -1
View File
@@ -33,6 +33,7 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
const stockValuesMin = Math.min(...data.priceHistory);
const stockGraphValues = data.priceHistory.map((value) => value - stockValuesMin + 50);
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
return (
<Flex h="100%" w="100%">
@@ -43,7 +44,7 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
h={height > 280 ? "75%" : "50%"}
data={stockGraphValues}
curveType="linear"
trendColors={{ positive: "green.7", negative: "red.7", neutral: "gray.6" }}
color={trendColor}
fillOpacity={0.6}
strokeWidth={2.5}
/>
@@ -4,6 +4,7 @@ import { Box, Card, Group, Stack, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -15,8 +16,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
switch (displayMode) {
case "percentage":
return `${Math.round(item.percentage)}%`;
case "absolute":
return `${item.used} / ${item.available}`;
case "absolute": {
const availableInBytes = Number(item.available);
const availableText = Number.isFinite(availableInBytes)
? humanFileSize(Math.round(availableInBytes))
: item.available;
return `${item.used} / ${availableText}`;
}
case "free":
return `${Math.round(100 - item.percentage)}% free`;
default: