feat(app-widget): add layout option (#3875)

This commit is contained in:
Meier Lukas
2025-08-17 17:49:05 +02:00
committed by GitHub
parent d7b2298d14
commit acd09c8c02
5 changed files with 99 additions and 6 deletions

View File

@@ -39,6 +39,7 @@ export const mapApp = (
pingEnabled: app.network.enabledStatusChecker,
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
layout: app.appearance.positionAppName,
} satisfies WidgetComponentProps<"app">["options"]),
layouts: boardSizes.map((size) => {
const shapeForSize = app.shape[size];

View File

@@ -1271,6 +1271,15 @@
},
"pingEnabled": {
"label": "Enable status check"
},
"layout": {
"label": "Layout",
"option": {
"row": "Horizontal",
"row-reverse": "Horizontal (reversed)",
"column": "Vertical",
"column-reverse": "Vertical (reversed)"
}
}
},
"error": {

View File

@@ -1,10 +1,12 @@
"use client";
import { Select } from "@mantine/core";
import { Group, Select } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { translateIfNecessary } from "@homarr/translation";
import type { stringOrTranslation } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -12,6 +14,7 @@ import { useFormContext } from "./form";
export type SelectOption =
| {
icon?: TablerIcon;
value: string;
label: stringOrTranslation;
}
@@ -23,10 +26,19 @@ export type inferSelectOptionValue<TOption extends SelectOption> = TOption exten
? TValue
: TOption;
const getIconFor = (options: SelectOption[], value: string) => {
const current = options.find((option) => (typeof option === "string" ? option : option.value) === value);
if (!current) return null;
if (typeof current === "string") return null;
return current.icon;
};
export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"select">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const CurrentIcon = getIconFor(options.options, inputProps.value as string);
return (
<Select
@@ -39,9 +51,29 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
label: translateIfNecessary(t, option.label) ?? option.value,
},
)}
leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
renderOption={({ option, checked }) => {
const Icon = getIconFor(options.options, option.value);
return (
<Group flex="1" gap="xs">
{Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
{option.label}
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
color="currentColor"
opacity={0.6}
size={18}
stroke={1.5}
/>
)}
</Group>
);
}}
description={options.withDescription ? tWidget("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}
{...inputProps}
/>
);
};

View File

@@ -56,7 +56,8 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, options.openInNewTab],
);
const tinyText = height < 100 || width < 100;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
return (
<AppLink
@@ -77,15 +78,22 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction="column"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
>
{options.showTitle && (
<Text className="app-title" fw={700} size={tinyText ? "8px" : "sm"} ta="center">
<Text
className="app-title"
fw={700}
size={isTiny ? "8px" : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
@@ -97,6 +105,8 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
</Flex>

View File

@@ -1,4 +1,11 @@
import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
import {
IconApps,
IconDeviceDesktopX,
IconLayoutBottombarExpand,
IconLayoutNavbarExpand,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRightExpand,
} from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -12,6 +19,40 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
openInNewTab: factory.switch({ defaultValue: true }),
showTitle: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
layout: factory.select({
options: [
{
label(t) {
return t("widget.app.option.layout.option.column");
},
value: "column",
icon: IconLayoutNavbarExpand,
},
{
label(t) {
return t("widget.app.option.layout.option.column-reverse");
},
value: "column-reverse",
icon: IconLayoutBottombarExpand,
},
{
label(t) {
return t("widget.app.option.layout.option.row");
},
value: "row",
icon: IconLayoutSidebarLeftExpand,
},
{
label(t) {
return t("widget.app.option.layout.option.row-reverse");
},
value: "row-reverse",
icon: IconLayoutSidebarRightExpand,
},
],
defaultValue: "column",
searchable: true,
}),
pingEnabled: factory.switch({ defaultValue: settings.enableStatusByDefault }),
}),
{