Compare commits

...
20 changed files with 228 additions and 109 deletions

No files matched your search

@@ -361,6 +361,7 @@ const LoadedBoardItemContent = ({
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef} widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef} sourceRef={sourceRef}
disabled={isAdvanced}
> >
<Box <Box
ref={sourceRef} ref={sourceRef}
@@ -44,6 +44,7 @@ interface WidgetContextMenuProps {
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef; widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>; sourceRef: RefObject<HTMLElement | null>;
disabled?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -53,6 +54,7 @@ export const WidgetContextMenu = ({
previewDimensions, previewDimensions,
widgetRuntimeRef, widgetRuntimeRef,
sourceRef, sourceRef,
disabled = false,
children, children,
}: WidgetContextMenuProps) => { }: WidgetContextMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -162,6 +164,7 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
setMenuOpened(false);
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -230,6 +233,7 @@ export const WidgetContextMenu = ({
const handleOpenAdvancedFocus = useCallback(() => { const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return; if (!sourceRef.current) return;
setMenuOpened(false);
openAdvancedFocus(item.id, sourceRef.current, { openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget: restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current, sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
@@ -256,7 +260,7 @@ export const WidgetContextMenu = ({
opened={menuOpened} opened={menuOpened}
onChange={setMenuOpened} onChange={setMenuOpened}
> >
<Menu.ContextMenu>{children}</Menu.ContextMenu> <Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}> <WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
{canOpenAdvancedFocus && ( {canOpenAdvancedFocus && (
<> <>
@@ -45,4 +45,13 @@
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1)); --mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1)); --mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1)); --mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
} }
@@ -3,6 +3,22 @@
overflow: visible; overflow: visible;
} }
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
unset, not merely inherited. Reset it here so every container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
--board-grid-drag-height: initial;
}
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always /* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity, mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
@@ -11,17 +27,14 @@
.viewport.scrollableViewport { .viewport.scrollableViewport {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped /* The grid content inside has a fixed pixel width matching this viewport's full
section's own GridPreviewLayer effect correctly avoids setting it on itself (see outer width (needed for the drag/resize math), so there's no room to reserve
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an for a scrollbar without it eating into - or overlapping - the content on
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself whichever side it renders. Hiding the native scrollbar track entirely (wheel/
while growing during a drag happening anywhere else on the board - a nested scrollable touch/drag scrolling still works) avoids that instead of trying to reserve
container sits inside that section's DOM tree, so the stray value cascades straight space for it. */
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in scrollbar-width: none;
section-grid.tsx, which only kicks in when the property is entirely unset, not merely -ms-overflow-style: none;
inherited. Reset it here so this element can never inherit a stray value from anywhere
above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
--board-grid-drag-height: initial; --board-grid-drag-height: initial;
} }
@@ -148,8 +148,25 @@ export const SectionGrid = ({
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset. // card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1; const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0; const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
// A collapsible container's toggle bar (see the `containerToggle` Button in
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
// not like the grid's own un-compensated logical-pixel math, so it needs the same
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
// all, the header bar covers the first row of the container's content instead of sitting
// above it. Only the vertical axis is affected - the toggle spans the full width already.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
: 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset; const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset; const viewportHeight = Math.max(
1,
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
);
// Items are positioned in a coordinate space sized to the *un-inset* column/row count // Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are // (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size. // deliberately smaller by outerCardInset to match the container's actual visible card size.
@@ -283,6 +300,7 @@ export const SectionGrid = ({
{ {
width: logicalWidth, width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`, height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`, "--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties } as CSSProperties
} }
@@ -317,6 +335,9 @@ export const SectionGrid = ({
); );
}; };
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR = const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]'; 'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]); const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
@@ -2,7 +2,7 @@
overflow: hidden; overflow: hidden;
} }
.header[data-desktop-visible="false"] { .header[data-visible="false"] {
display: none; display: none;
} }
@@ -73,39 +73,38 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.userMenu {
display: flex;
padding-inline: rem(6px);
}
.mobileContent { .mobileContent {
display: none; display: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs); gap: var(--mantine-spacing-xs);
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-inline: var(--mantine-spacing-xs); padding-inline: var(--mantine-spacing-xs);
} }
.mobileIdentity, .mobileZone {
.mobileActions {
min-width: 0; min-width: 0;
overflow-x: auto;
scrollbar-width: none;
} }
.mobileIdentity { .mobileZone::-webkit-scrollbar {
flex: 1 1 auto; display: none;
overflow: hidden;
} }
.mobileIdentity > :last-child { .mobileZone[data-zone="left"] {
flex: 1 1 auto; justify-content: flex-start;
min-width: 0;
overflow: hidden;
} }
.mobileIdentity h2 { .mobileZone[data-zone="right"] {
overflow: hidden; justify-content: flex-end;
text-overflow: ellipsis; max-width: 72vw;
}
.mobileActions {
flex: 0 0 auto;
} }
.floatingControls { .floatingControls {
@@ -155,21 +154,12 @@
} }
@media (max-width: $mantine-breakpoint-sm) { @media (max-width: $mantine-breakpoint-sm) {
.header[data-desktop-visible="false"] {
display: block;
}
.desktopContent { .desktopContent {
display: none; display: none;
} }
.mobileContent { .mobileContent {
display: flex; display: grid;
}
.floatingControls,
.floatingControlsCorner {
display: none;
} }
} }
@@ -3,15 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core"; import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import { import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} 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";
@@ -81,7 +73,10 @@ export const ConfigurableHeader = ({
return ( return (
<BoardSwitcher> <BoardSwitcher>
{(boardSwitcher) => { {(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) => const renderItems = (
items: HeaderItem[],
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
) =>
items.map((item) => ( items.map((item) => (
<HeaderItem <HeaderItem
key={getHeaderItemKey(item)} key={getHeaderItemKey(item)}
@@ -95,8 +90,8 @@ export const ConfigurableHeader = ({
isAdmin={isAdmin} isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled} isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined} board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay} searchDisplay={display.search}
logoDisplay={headerPreferences.logoDisplay} logoDisplay={display.logo}
boardSwitcher={boardSwitcher} boardSwitcher={boardSwitcher}
assistant={assistant} assistant={assistant}
openDockerModal={openDockerModal} openDockerModal={openDockerModal}
@@ -106,13 +101,19 @@ export const ConfigurableHeader = ({
/> />
)); ));
const desktopDisplay = {
search: headerPreferences.searchDisplay,
logo: headerPreferences.logoDisplay,
};
const mobileDisplay = { search: "icon", logo: "logo" } as const;
return ( return (
<> <>
<AppShellHeader <AppShellHeader
maw="100vw" maw="100vw"
zIndex="var(--homarr-z-index-board-header)" zIndex="var(--homarr-z-index-board-header)"
className={classes.header} className={classes.header}
data-desktop-visible={headerPreferences.visible} data-visible={headerPreferences.visible}
data-advanced-focus-background data-advanced-focus-background
data-app-shell-header data-app-shell-header
> >
@@ -120,37 +121,26 @@ export const ConfigurableHeader = ({
<div className={classes.content}> <div className={classes.content}>
<div className={classes.zone} data-zone="left"> <div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left)} {renderItems(headerPreferences.zones.left, desktopDisplay)}
</div> </div>
<div className={classes.zone} data-zone="center"> <div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center)} {renderItems(headerPreferences.zones.center, desktopDisplay)}
</div> </div>
<div className={classes.zone} data-zone="right"> <div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right)} {renderItems(headerPreferences.zones.right, desktopDisplay)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div> </div>
</div> </div>
</div> </div>
<div className={classes.mobileContent}> <div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap"> <Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} /> {renderItems(headerPreferences.zones.left, mobileDisplay)}
</Group> </Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap"> <Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
{actions} {renderItems(headerPreferences.zones.center, mobileDisplay)}
{boardEditAction} {renderItems(headerPreferences.zones.right, mobileDisplay)}
{boardSettingsAction} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group> </Group>
</div> </div>
</AppShellHeader> </AppShellHeader>
@@ -298,7 +288,9 @@ const HeaderItem = ({
return ( return (
<Tooltip label={boardUnavailableLabel}> <Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled> <HeaderButton aria-label={boardUnavailableLabel} disabled>
<IconLayoutDashboard size={20} stroke={1.5} /> <Avatar size={22} radius="sm">
?
</Avatar>
</HeaderButton> </HeaderButton>
</Tooltip> </Tooltip>
); );
@@ -416,14 +408,19 @@ const HeaderItem = ({
return ( return (
<TourTarget id="board-user-menu"> <TourTarget id="board-user-menu">
<UserButtonClient <div className={classes.userMenu}>
avatar={avatar} <UserButtonClient
isAdmin={isAdmin} avatar={avatar}
isDockerEnabled={isDockerEnabled} isAdmin={isAdmin}
boardSwitcher={boardSwitcher} isDockerEnabled={isDockerEnabled}
/> boardSwitcher={boardSwitcher}
/>
</div>
</TourTarget> </TourTarget>
); );
}; };
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; const getBoardInitial = (name: string) => {
const firstCharacter = Array.from(name.trim())[0] ?? "?";
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
};
+1 -1
View File
@@ -22,7 +22,7 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom); const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps(); const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings(); const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 }; const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
return ( return (
<AppShell <AppShell
@@ -6,6 +6,7 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
import { import {
IconBrandDocker, IconBrandDocker,
IconHome, IconHome,
IconLayoutDashboard,
IconLogin, IconLogin,
IconLogout, IconLogout,
IconReplace, IconReplace,
@@ -16,6 +17,8 @@ import {
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client"; import { signOut, useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions"; import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -46,6 +49,8 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
const t = useI18n("common.userAvatar.menu"); const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const session = useSession(); const session = useSession();
const board = useOptionalBoard();
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
const { logoutUrl } = useAuthContext(); const { logoutUrl } = useAuthContext();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
@@ -67,6 +72,15 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")} {t("homeBoard")}
</Menu.Item> </Menu.Item>
{board && boardPermissions?.hasChangeAccess && (
<Menu.Item
component={Link}
href={`/boards/${encodeURIComponent(board.name)}/settings`}
leftSection={<IconLayoutDashboard size="1rem" />}
>
{tBoard("action.settings")}
</Menu.Item>
)}
<Menu.Item <Menu.Item
leftSection={<IconReplace size="1rem" />} leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>} rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
@@ -49,8 +49,7 @@ export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
message: tBoard("action.create.notification.success.message", { name: values.name }), message: tBoard("action.create.notification.success.message", { name: values.name }),
}); });
const name = encodeURIComponent(result.name); const name = encodeURIComponent(result.name);
const layoutId = encodeURIComponent(result.layoutId); router.push(`/boards/${name}`);
router.push(`/boards/${name}?layout=${layoutId}&edit=true&add=true`);
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
+22 -7
View File
@@ -7,6 +7,7 @@ import { useI18n } from "@homarr/translation/client";
interface ModalFormFooterProps { interface ModalFormFooterProps {
onCancel: () => void; onCancel: () => void;
form?: string;
submitLabel?: string; submitLabel?: string;
cancelLabel?: string; cancelLabel?: string;
loading?: boolean; loading?: boolean;
@@ -18,24 +19,38 @@ const ActionButtons = ({
submitLabel, submitLabel,
cancelLabel, cancelLabel,
loading, loading,
}: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading">) => { form,
const t = useI18n(); }: Pick<ModalFormFooterProps, "onCancel" | "submitLabel" | "cancelLabel" | "loading" | "form">) => {
const t = useI18n("common.action");
return ( return (
<> <>
<Button onClick={onCancel} variant="subtle" color="gray"> <Button onClick={onCancel} variant="subtle" color="gray">
{cancelLabel ?? t("common.action.cancel")} {cancelLabel ?? t("cancel")}
</Button> </Button>
<Button type="submit" loading={loading}> <Button type="submit" form={form} loading={loading}>
{submitLabel ?? t("common.action.saveChanges")} {submitLabel ?? t("saveChanges")}
</Button> </Button>
</> </>
); );
}; };
export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => { export const ModalFormFooter = ({
onCancel,
form,
submitLabel,
cancelLabel,
loading,
leftSection,
}: ModalFormFooterProps) => {
const actionButtons = ( const actionButtons = (
<ActionButtons onCancel={onCancel} submitLabel={submitLabel} cancelLabel={cancelLabel} loading={loading} /> <ActionButtons
onCancel={onCancel}
form={form}
submitLabel={submitLabel}
cancelLabel={cancelLabel}
loading={loading}
/>
); );
if (leftSection) { if (leftSection) {
+10
View File
@@ -2430,6 +2430,16 @@
"label": "Animate weather icon", "label": "Animate weather icon",
"description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested." "description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested."
}, },
"colorWeatherByDayNight": {
"label": "Color weather by day/night",
"description": "Tint the weather icon and temperature with a day color or a night color, based on whether it's currently day or night at the chosen location."
},
"dayWeatherColor": {
"label": "Day color"
},
"nightWeatherColor": {
"label": "Night color"
},
"worldClockCities": { "worldClockCities": {
"label": "World clock cities", "label": "World clock cities",
"description": "Add and order up to six cities for advanced view." "description": "Add and order up to six cities for advanced view."
@@ -16,7 +16,7 @@ export const headerBuiltinItemIds = [
"user", "user",
] as const; ] as const;
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const; export const requiredHeaderBuiltinItemIds = ["user"] as const;
export const headerPreferenceItemIds = headerBuiltinItemIds; export const headerPreferenceItemIds = headerBuiltinItemIds;
export const headerItemIds = headerBuiltinItemIds; export const headerItemIds = headerBuiltinItemIds;
@@ -132,6 +132,9 @@ const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "option
locationName={options.weatherLocation.name} locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit} isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon} animateIcon={options.animateWeatherIcon}
colorByDayNight={options.colorWeatherByDayNight}
dayColor={options.dayWeatherColor}
nightColor={options.nightWeatherColor}
detailed detailed
/> />
</Paper> </Paper>
+3
View File
@@ -54,6 +54,9 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
locationName={options.weatherLocation.name} locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit} isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon} animateIcon={options.animateWeatherIcon}
colorByDayNight={options.colorWeatherByDayNight}
dayColor={options.dayWeatherColor}
nightColor={options.nightWeatherColor}
detailed={false} detailed={false}
/> />
</Box> </Box>
+38
View File
@@ -12,6 +12,23 @@ import {
const timeZoneOptions = getTimeZoneOptions(); const timeZoneOptions = getTimeZoneOptions();
// Mantine theme color names, used to let users pick their own day/night weather colors.
const weatherColorOptions: string[] = [
"red",
"pink",
"grape",
"violet",
"indigo",
"blue",
"cyan",
"teal",
"green",
"lime",
"yellow",
"orange",
"gray",
];
export const { definition, componentLoader } = createWidgetDefinition("clock", { export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock, icon: IconClock,
supportsAdvancedFocus: true, supportsAdvancedFocus: true,
@@ -101,6 +118,18 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
defaultValue: false, defaultValue: false,
withDescription: true, withDescription: true,
}), }),
colorWeatherByDayNight: factory.switch({
defaultValue: false,
withDescription: true,
}),
dayWeatherColor: factory.select({
options: weatherColorOptions,
defaultValue: "orange",
}),
nightWeatherColor: factory.select({
options: weatherColorOptions,
defaultValue: "blue",
}),
worldClockCities: factory.timezoneList({ worldClockCities: factory.timezoneList({
defaultValue: defaultWorldClockCities.map((city) => ({ ...city })), defaultValue: defaultWorldClockCities.map((city) => ({ ...city })),
maxValues: maximumWorldClockCities, maxValues: maximumWorldClockCities,
@@ -131,6 +160,15 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
animateWeatherIcon: { animateWeatherIcon: {
shouldHide: (options) => !options.showWeather, shouldHide: (options) => !options.showWeather,
}, },
colorWeatherByDayNight: {
shouldHide: (options) => !options.showWeather,
},
dayWeatherColor: {
shouldHide: (options) => !options.showWeather || !options.colorWeatherByDayNight,
},
nightWeatherColor: {
shouldHide: (options) => !options.showWeather || !options.colorWeatherByDayNight,
},
}, },
); );
}, },
+14 -4
View File
@@ -14,6 +14,9 @@ interface ClockWeatherSummaryProps {
locationName: string; locationName: string;
isFahrenheit: boolean; isFahrenheit: boolean;
animateIcon: boolean; animateIcon: boolean;
colorByDayNight: boolean;
dayColor: string;
nightColor: string;
detailed: boolean; detailed: boolean;
} }
@@ -23,6 +26,9 @@ export const ClockWeatherSummary = ({
locationName, locationName,
isFahrenheit, isFahrenheit,
animateIcon, animateIcon,
colorByDayNight,
dayColor,
nightColor,
detailed, detailed,
}: ClockWeatherSummaryProps) => { }: ClockWeatherSummaryProps) => {
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -46,6 +52,10 @@ export const ClockWeatherSummary = ({
return `${Math.round(preferred)}${unit}`; return `${Math.round(preferred)}${unit}`;
}; };
const weatherColor = colorByDayNight
? `var(--mantine-color-${weather.current.isDay ? dayColor : nightColor}-5)`
: undefined;
return ( return (
<Stack gap={detailed ? 3 : 2} align={detailed ? "flex-start" : "center"}> <Stack gap={detailed ? 3 : 2} align={detailed ? "flex-start" : "center"}>
{detailed ? ( {detailed ? (
@@ -54,9 +64,9 @@ export const ClockWeatherSummary = ({
animated={animateIcon} animated={animateIcon}
code={weather.current.weatherCode} code={weather.current.weatherCode}
isDay={weather.current.isDay} isDay={weather.current.isDay}
style={zoomCompensatedSize(28)} style={{ ...zoomCompensatedSize(28), ...(weatherColor ? { color: weatherColor } : undefined) }}
/> />
<Text size="sm" fw={600}> <Text size="sm" fw={600} c={weatherColor}>
{Math.round(temperature)} {Math.round(temperature)}
{unit} {unit}
</Text> </Text>
@@ -68,9 +78,9 @@ export const ClockWeatherSummary = ({
animated={animateIcon} animated={animateIcon}
code={weather.current.weatherCode} code={weather.current.weatherCode}
isDay={weather.current.isDay} isDay={weather.current.isDay}
style={zoomCompensatedSize(26)} style={{ ...zoomCompensatedSize(26), ...(weatherColor ? { color: weatherColor } : undefined) }}
/> />
<Text size="xs" c="dimmed"> <Text size="xs" c={weatherColor ?? "dimmed"}>
{Math.round(temperature)} {Math.round(temperature)}
{unit} {unit}
</Text> </Text>
@@ -143,6 +143,7 @@
margin-inline: calc(var(--mantine-spacing-md) * -1); margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1); margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding-bottom: max(var(--mantine-spacing-sm), env(safe-area-inset-bottom));
border-top: 1px solid var(--mantine-color-default-border); border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -36,7 +36,7 @@ import { objectEntries } from "@homarr/common";
import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { IntegrationProvider, useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals"; import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
import { SpotlightProvider } from "@homarr/spotlight"; import { SpotlightProvider } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -674,16 +674,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const footer = ( const footer = (
<Box className={classes.footer}> <Box className={classes.footer}>
<Group justify="end" wrap="nowrap"> <ModalFormFooter onCancel={actions.closeModal} form={widgetFormId} loading={isSubmitting} />
<Group gap="xs" wrap="nowrap">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Box> </Box>
); );
@@ -26,7 +26,7 @@ export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26,
let animationClass = ""; let animationClass = "";
if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code); if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code);
return ( return (
<span className={`weather-anim-wrapper ${animationClass}`}> <span className={`weather-anim-wrapper ${animationClass}`} data-is-day={isDay === false ? "false" : "true"}>
<WeatherIcon code={code} isDay={isDay} size={size} style={style} /> <WeatherIcon code={code} isDay={isDay} size={size} style={style} />
</span> </span>
); );