feat(board): allow to set icon color of widgets (#2228)
Co-authored-by: Andre Silva <asilva01@acuitysso.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
|
|||||||
colors: {
|
colors: {
|
||||||
primaryColor: generateColors(board.primaryColor),
|
primaryColor: generateColors(board.primaryColor),
|
||||||
secondaryColor: generateColors(board.secondaryColor),
|
secondaryColor: generateColors(board.secondaryColor),
|
||||||
|
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
primaryColor: board.primaryColor,
|
primaryColor: board.primaryColor,
|
||||||
secondaryColor: board.secondaryColor,
|
secondaryColor: board.secondaryColor,
|
||||||
opacity: board.opacity,
|
opacity: board.opacity,
|
||||||
|
iconColor: board.iconColor ?? "",
|
||||||
itemRadius: board.itemRadius,
|
itemRadius: board.itemRadius,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -101,6 +102,12 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<ColorInput
|
||||||
|
label={t("board.field.iconColor.label")}
|
||||||
|
format="hex"
|
||||||
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
|
{...form.getInputProps("iconColor")}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label={t("board.field.itemRadius.label")}
|
label={t("board.field.itemRadius.label")}
|
||||||
description={t("board.field.itemRadius.description")}
|
description={t("board.field.itemRadius.description")}
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
primaryColor: input.primaryColor,
|
primaryColor: input.primaryColor,
|
||||||
secondaryColor: input.secondaryColor,
|
secondaryColor: input.secondaryColor,
|
||||||
opacity: input.opacity,
|
opacity: input.opacity,
|
||||||
|
iconColor: input.iconColor,
|
||||||
|
|
||||||
// custom css
|
// custom css
|
||||||
customCss: input.customCss,
|
customCss: input.customCss,
|
||||||
|
|||||||
1
packages/db/migrations/mysql/0027_acoustic_karma.sql
Normal file
1
packages/db/migrations/mysql/0027_acoustic_karma.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `board` ADD `icon_color` text;
|
||||||
1826
packages/db/migrations/mysql/meta/0027_snapshot.json
Normal file
1826
packages/db/migrations/mysql/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,13 @@
|
|||||||
"when": 1739907771355,
|
"when": 1739907771355,
|
||||||
"tag": "0026_add-border-radius",
|
"tag": "0026_add-border-radius",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1739915526818,
|
||||||
|
"tag": "0027_acoustic_karma",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/db/migrations/sqlite/0027_wooden_blizzard.sql
Normal file
1
packages/db/migrations/sqlite/0027_wooden_blizzard.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `board` ADD `icon_color` text;
|
||||||
1751
packages/db/migrations/sqlite/meta/0027_snapshot.json
Normal file
1751
packages/db/migrations/sqlite/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,13 @@
|
|||||||
"when": 1739907755789,
|
"when": 1739907755789,
|
||||||
"tag": "0026_add-border-radius",
|
"tag": "0026_add-border-radius",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739915486467,
|
||||||
|
"tag": "0027_wooden_blizzard",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export const boards = mysqlTable("board", {
|
|||||||
opacity: int().default(100).notNull(),
|
opacity: int().default(100).notNull(),
|
||||||
customCss: text(),
|
customCss: text(),
|
||||||
columnCount: int().default(10).notNull(),
|
columnCount: int().default(10).notNull(),
|
||||||
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: boolean().default(false).notNull(),
|
disableStatus: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export const boards = sqliteTable("board", {
|
|||||||
opacity: int().default(100).notNull(),
|
opacity: int().default(100).notNull(),
|
||||||
customCss: text(),
|
customCss: text(),
|
||||||
columnCount: int().default(10).notNull(),
|
columnCount: int().default(10).notNull(),
|
||||||
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2088,6 +2088,9 @@
|
|||||||
"opacity": {
|
"opacity": {
|
||||||
"label": "Opacity"
|
"label": "Opacity"
|
||||||
},
|
},
|
||||||
|
"iconColor": {
|
||||||
|
"label": "Icon color"
|
||||||
|
},
|
||||||
"customCss": {
|
"customCss": {
|
||||||
"label": "Custom css for this board",
|
"label": "Custom css for this board",
|
||||||
"description": "Further, customize your dashboard using CSS, only recommended for experienced users",
|
"description": "Further, customize your dashboard using CSS, only recommended for experienced users",
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ export { UserAvatarGroup } from "./user-avatar-group";
|
|||||||
export { CustomPasswordInput } from "./password-input/password-input";
|
export { CustomPasswordInput } from "./password-input/password-input";
|
||||||
export { IntegrationAvatar } from "./integration-avatar";
|
export { IntegrationAvatar } from "./integration-avatar";
|
||||||
export { BetaBadge } from "./beta-badge";
|
export { BetaBadge } from "./beta-badge";
|
||||||
|
export { MaskedImage } from "./masked-image";
|
||||||
|
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||||
|
|||||||
3
packages/ui/src/components/masked-image.module.css
Normal file
3
packages/ui/src/components/masked-image.module.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.maskedImage {
|
||||||
|
background-color: var(--image-color);
|
||||||
|
}
|
||||||
49
packages/ui/src/components/masked-image.tsx
Normal file
49
packages/ui/src/components/masked-image.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { getThemeColor, useMantineTheme } from "@mantine/core";
|
||||||
|
import type { MantineColor } from "@mantine/core";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
import type { Property } from "csstype";
|
||||||
|
|
||||||
|
import classes from "./masked-image.module.css";
|
||||||
|
|
||||||
|
interface MaskedImageProps {
|
||||||
|
imageUrl: string;
|
||||||
|
color: MantineColor;
|
||||||
|
alt?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
maskSize?: Property.MaskSize;
|
||||||
|
maskRepeat?: Property.MaskRepeat;
|
||||||
|
maskPosition?: Property.MaskPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaskedImage = ({
|
||||||
|
imageUrl,
|
||||||
|
color,
|
||||||
|
alt,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
maskSize = "contain",
|
||||||
|
maskRepeat = "no-repeat",
|
||||||
|
maskPosition = "center",
|
||||||
|
}: MaskedImageProps) => {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={combineClasses(classes.maskedImage, className)}
|
||||||
|
role="img"
|
||||||
|
aria-label={alt}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...style,
|
||||||
|
"--image-color": getThemeColor(color, theme),
|
||||||
|
maskSize,
|
||||||
|
maskRepeat,
|
||||||
|
maskPosition,
|
||||||
|
maskImage: `url(${imageUrl})`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
packages/ui/src/components/masked-or-normal-image.tsx
Normal file
55
packages/ui/src/components/masked-or-normal-image.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Image } from "@mantine/core";
|
||||||
|
import type { MantineColor } from "@mantine/core";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
import type { Property } from "csstype";
|
||||||
|
|
||||||
|
import { MaskedImage } from "./masked-image";
|
||||||
|
|
||||||
|
interface MaskedOrNormalImageProps {
|
||||||
|
imageUrl: string;
|
||||||
|
hasColor?: boolean;
|
||||||
|
color?: MantineColor;
|
||||||
|
alt?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
fit?: Property.ObjectFit;
|
||||||
|
maskSize?: Property.MaskSize;
|
||||||
|
maskRepeat?: Property.MaskRepeat;
|
||||||
|
maskPosition?: Property.MaskPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaskedOrNormalImage = ({
|
||||||
|
imageUrl,
|
||||||
|
hasColor = true,
|
||||||
|
color = "iconColor",
|
||||||
|
alt,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
fit = "contain",
|
||||||
|
maskSize = "contain",
|
||||||
|
maskRepeat = "no-repeat",
|
||||||
|
maskPosition = "center",
|
||||||
|
}: MaskedOrNormalImageProps) => {
|
||||||
|
return hasColor ? (
|
||||||
|
<MaskedImage
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
color={color}
|
||||||
|
alt={alt}
|
||||||
|
className={combineClasses("masked-image", className)}
|
||||||
|
maskSize={maskSize}
|
||||||
|
maskRepeat={maskRepeat}
|
||||||
|
maskPosition={maskPosition}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
className={combineClasses("normal-image", className)}
|
||||||
|
src={imageUrl}
|
||||||
|
alt={alt}
|
||||||
|
fit={fit}
|
||||||
|
style={{ ...style }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,11 @@ import { commonItemSchema, createSectionSchema } from "./shared";
|
|||||||
|
|
||||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
|
||||||
|
const hexColorNullableSchema = hexColorSchema
|
||||||
|
.or(z.literal(""))
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => (value?.trim().length === 0 ? null : value));
|
||||||
|
|
||||||
const boardNameSchema = z
|
const boardNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -58,6 +63,7 @@ const savePartialSettingsSchema = z
|
|||||||
opacity: z.number().min(0).max(100),
|
opacity: z.number().min(0).max(100),
|
||||||
customCss: z.string().max(16384),
|
customCss: z.string().max(16384),
|
||||||
columnCount: z.number().min(1).max(24),
|
columnCount: z.number().min(1).max(24),
|
||||||
|
iconColor: hexColorNullableSchema,
|
||||||
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
|
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
|
||||||
disableStatus: z.boolean(),
|
disableStatus: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
transition: scale 0.2s ease-in-out;
|
transition: scale 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appIcon:hover {
|
.appWithUrl:hover > .appIcon {
|
||||||
scale: 0.9;
|
scale: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appWithUrl:hover > div.appIcon {
|
||||||
|
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
|||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./app.module.css";
|
import classes from "./app.module.css";
|
||||||
@@ -69,7 +70,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
|
|||||||
styles={{ tooltip: { maxWidth: 300 } }}
|
styles={{ tooltip: { maxWidth: 300 } }}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
className={combineClasses("app-flex-wrapper", app.name, app.id)}
|
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
|
||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
direction="column"
|
direction="column"
|
||||||
@@ -82,7 +83,16 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
|
|||||||
{app.name}
|
{app.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<img src={app.iconUrl} alt={app.name} className={combineClasses(classes.appIcon, "app-icon")} />
|
<MaskedOrNormalImage
|
||||||
|
imageUrl={app.iconUrl}
|
||||||
|
hasColor={board.iconColor !== null}
|
||||||
|
alt={app.name}
|
||||||
|
className={combineClasses(classes.appIcon, "app-icon")}
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip.Floating>
|
</Tooltip.Floating>
|
||||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
.card:hover {
|
.card:hover {
|
||||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card:hover > div > div.bookmarkIcon {
|
||||||
|
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Anchor, Box, Card, Divider, Flex, Group, Image, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
import { Anchor, Box, Card, Divider, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
|
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./bookmark.module.css";
|
import classes from "./bookmark.module.css";
|
||||||
|
|
||||||
export default function BookmarksWidget({ options, width, height, itemId }: WidgetComponentProps<"bookmarks">) {
|
export default function BookmarksWidget({ options, width, height, itemId }: WidgetComponentProps<"bookmarks">) {
|
||||||
|
const board = useRequiredBoard();
|
||||||
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
|
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
|
||||||
select(data) {
|
select(data) {
|
||||||
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||||
@@ -50,6 +53,7 @@ export default function BookmarksWidget({ options, width, height, itemId }: Widg
|
|||||||
hideIcon={options.hideIcon}
|
hideIcon={options.hideIcon}
|
||||||
hideHostname={options.hideHostname}
|
hideHostname={options.hideHostname}
|
||||||
openNewTab={options.openNewTab}
|
openNewTab={options.openNewTab}
|
||||||
|
hasIconColor={board.iconColor !== null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{options.layout !== "grid" && (
|
{options.layout !== "grid" && (
|
||||||
@@ -59,6 +63,7 @@ export default function BookmarksWidget({ options, width, height, itemId }: Widg
|
|||||||
hideIcon={options.hideIcon}
|
hideIcon={options.hideIcon}
|
||||||
hideHostname={options.hideHostname}
|
hideHostname={options.hideHostname}
|
||||||
openNewTab={options.openNewTab}
|
openNewTab={options.openNewTab}
|
||||||
|
hasIconColor={board.iconColor !== null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -71,9 +76,10 @@ interface FlexLayoutProps {
|
|||||||
hideIcon: boolean;
|
hideIcon: boolean;
|
||||||
hideHostname: boolean;
|
hideHostname: boolean;
|
||||||
openNewTab: boolean;
|
openNewTab: boolean;
|
||||||
|
hasIconColor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlexLayout = ({ data, direction, hideIcon, hideHostname, openNewTab }: FlexLayoutProps) => {
|
const FlexLayout = ({ data, direction, hideIcon, hideHostname, openNewTab, hasIconColor }: FlexLayoutProps) => {
|
||||||
return (
|
return (
|
||||||
<Flex direction={direction} gap="0" h="100%" w="100%">
|
<Flex direction={direction} gap="0" h="100%" w="100%">
|
||||||
{data.map((app, index) => (
|
{data.map((app, index) => (
|
||||||
@@ -102,9 +108,9 @@ const FlexLayout = ({ data, direction, hideIcon, hideHostname, openNewTab }: Fle
|
|||||||
p={0}
|
p={0}
|
||||||
>
|
>
|
||||||
{direction === "row" ? (
|
{direction === "row" ? (
|
||||||
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} />
|
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} hasIconColor={hasIconColor} />
|
||||||
) : (
|
) : (
|
||||||
<HorizontalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} />
|
<HorizontalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} hasIconColor={hasIconColor} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@@ -121,9 +127,10 @@ interface GridLayoutProps {
|
|||||||
hideIcon: boolean;
|
hideIcon: boolean;
|
||||||
hideHostname: boolean;
|
hideHostname: boolean;
|
||||||
openNewTab: boolean;
|
openNewTab: boolean;
|
||||||
|
hasIconColor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridLayout = ({ data, width, height, hideIcon, hideHostname, openNewTab }: GridLayoutProps) => {
|
const GridLayout = ({ data, width, height, hideIcon, hideHostname, openNewTab, hasIconColor }: GridLayoutProps) => {
|
||||||
// Calculates the perfect number of columns for the grid layout based on the width and height in pixels and the number of items
|
// Calculates the perfect number of columns for the grid layout based on the width and height in pixels and the number of items
|
||||||
const columns = Math.ceil(Math.sqrt(data.length * (width / height)));
|
const columns = Math.ceil(Math.sqrt(data.length * (width / height)));
|
||||||
|
|
||||||
@@ -146,7 +153,7 @@ const GridLayout = ({ data, width, height, hideIcon, hideHostname, openNewTab }:
|
|||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Card withBorder style={{ containerType: "size" }} h="100%" className={classes.card} p="5cqmin">
|
<Card withBorder style={{ containerType: "size" }} h="100%" className={classes.card} p="5cqmin">
|
||||||
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} />
|
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} hasIconColor={hasIconColor} />
|
||||||
</Card>
|
</Card>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
))}
|
))}
|
||||||
@@ -158,10 +165,12 @@ const VerticalItem = ({
|
|||||||
app,
|
app,
|
||||||
hideIcon,
|
hideIcon,
|
||||||
hideHostname,
|
hideHostname,
|
||||||
|
hasIconColor,
|
||||||
}: {
|
}: {
|
||||||
app: RouterOutputs["app"]["byIds"][number];
|
app: RouterOutputs["app"]["byIds"][number];
|
||||||
hideIcon: boolean;
|
hideIcon: boolean;
|
||||||
hideHostname: boolean;
|
hideHostname: boolean;
|
||||||
|
hasIconColor: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="5cqmin">
|
<Stack h="100%" gap="5cqmin">
|
||||||
@@ -169,17 +178,18 @@ const VerticalItem = ({
|
|||||||
{app.name}
|
{app.name}
|
||||||
</Text>
|
</Text>
|
||||||
{!hideIcon && (
|
{!hideIcon && (
|
||||||
<Image
|
<MaskedOrNormalImage
|
||||||
|
imageUrl={app.iconUrl}
|
||||||
|
hasColor={hasIconColor}
|
||||||
|
alt={app.name}
|
||||||
|
className={classes.bookmarkIcon}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
objectFit: "contain",
|
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
}}
|
}}
|
||||||
src={app.iconUrl}
|
|
||||||
alt={app.name}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hideHostname && (
|
{!hideHostname && (
|
||||||
@@ -195,26 +205,29 @@ const HorizontalItem = ({
|
|||||||
app,
|
app,
|
||||||
hideIcon,
|
hideIcon,
|
||||||
hideHostname,
|
hideHostname,
|
||||||
|
hasIconColor,
|
||||||
}: {
|
}: {
|
||||||
app: RouterOutputs["app"]["byIds"][number];
|
app: RouterOutputs["app"]["byIds"][number];
|
||||||
hideIcon: boolean;
|
hideIcon: boolean;
|
||||||
hideHostname: boolean;
|
hideHostname: boolean;
|
||||||
|
hasIconColor: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!hideIcon && (
|
{!hideIcon && (
|
||||||
<Image
|
<MaskedOrNormalImage
|
||||||
|
imageUrl={app.iconUrl}
|
||||||
|
hasColor={hasIconColor}
|
||||||
|
alt={app.name}
|
||||||
|
className={classes.bookmarkIcon}
|
||||||
style={{
|
style={{
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
objectFit: "contain",
|
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
minHeight: "100cqh",
|
minHeight: "100cqh",
|
||||||
maxHeight: "100cqh",
|
maxHeight: "100cqh",
|
||||||
minWidth: "100cqh",
|
minWidth: "100cqh",
|
||||||
maxWidth: "100cqh",
|
maxWidth: "100cqh",
|
||||||
}}
|
}}
|
||||||
src={app.iconUrl}
|
|
||||||
alt={app.name}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Stack justify="space-between" gap={0}>
|
<Stack justify="space-between" gap={0}>
|
||||||
|
|||||||
@@ -3,29 +3,19 @@
|
|||||||
import "../../widgets-common.css";
|
import "../../widgets-common.css";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { ActionIcon, Badge, Button, Card, Flex, ScrollArea, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Flex,
|
|
||||||
Image,
|
|
||||||
ScrollArea,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
|
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useIntegrationConnected } from "@homarr/common";
|
import { useIntegrationConnected } from "@homarr/common";
|
||||||
import { integrationDefs } from "@homarr/definitions";
|
import { integrationDefs } from "@homarr/definitions";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||||
|
|
||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -39,6 +29,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
integrationIds,
|
integrationIds,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
}: WidgetComponentProps<typeof widgetKind>) {
|
}: WidgetComponentProps<typeof widgetKind>) {
|
||||||
|
const board = useRequiredBoard();
|
||||||
// DnsHole integrations with interaction permissions
|
// DnsHole integrations with interaction permissions
|
||||||
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
|
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
|
||||||
.map(({ id }) => id)
|
.map(({ id }) => id)
|
||||||
@@ -275,6 +266,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
setSelectedIntegrationIds={setSelectedIntegrationIds}
|
setSelectedIntegrationIds={setSelectedIntegrationIds}
|
||||||
open={open}
|
open={open}
|
||||||
t={t}
|
t={t}
|
||||||
|
hasIconColor={board.iconColor !== null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -297,6 +289,7 @@ interface ControlsCardProps {
|
|||||||
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
||||||
open: () => void;
|
open: () => void;
|
||||||
t: TranslationFunction;
|
t: TranslationFunction;
|
||||||
|
hasIconColor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ControlsCard: React.FC<ControlsCardProps> = ({
|
const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||||
@@ -306,6 +299,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
setSelectedIntegrationIds,
|
setSelectedIntegrationIds,
|
||||||
open,
|
open,
|
||||||
t,
|
t,
|
||||||
|
hasIconColor,
|
||||||
}) => {
|
}) => {
|
||||||
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||||
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
|
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
|
||||||
@@ -313,6 +307,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
// Use all factors to infer the state of the action buttons
|
// Use all factors to infer the state of the action buttons
|
||||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
||||||
|
|
||||||
|
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`dns-hole-controls-integration-item-outer-shell dns-hole-controls-integration-item-${data.integration.id} dns-hole-controls-integration-item-${data.integration.name}`}
|
className={`dns-hole-controls-integration-item-outer-shell dns-hole-controls-integration-item-${data.integration.id} dns-hole-controls-integration-item-${data.integration.name}`}
|
||||||
@@ -322,13 +318,16 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
radius="2.5cqmin"
|
radius="2.5cqmin"
|
||||||
>
|
>
|
||||||
<Flex className="dns-hole-controls-item-container" gap="4cqmin" align="center" direction="row">
|
<Flex className="dns-hole-controls-item-container" gap="4cqmin" align="center" direction="row">
|
||||||
<Image
|
<MaskedOrNormalImage
|
||||||
|
imageUrl={iconUrl}
|
||||||
|
hasColor={hasIconColor}
|
||||||
|
alt={data.integration.name}
|
||||||
className="dns-hole-controls-item-icon"
|
className="dns-hole-controls-item-icon"
|
||||||
src={integrationDefs[data.integration.kind].iconUrl}
|
style={{
|
||||||
w="20cqmin"
|
height: "20cqmin",
|
||||||
h="20cqmin"
|
width: "20cqmin",
|
||||||
fit="contain"
|
filter: !isConnected ? "grayscale(100%)" : undefined,
|
||||||
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
|
}}
|
||||||
/>
|
/>
|
||||||
<Flex className="dns-hole-controls-item-data-stack" direction="column" gap="1.5cqmin">
|
<Flex className="dns-hole-controls-item-data-stack" direction="column" gap="1.5cqmin">
|
||||||
<Text className="dns-hole-controls-item-integration-name" fz="7cqmin">
|
<Text className="dns-hole-controls-item-integration-name" fz="7cqmin">
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -18934,4 +18934,4 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.24.2
|
zod: 3.24.2
|
||||||
|
|
||||||
zod@3.24.2: {}
|
zod@3.24.2: {}
|
||||||
Reference in New Issue
Block a user