diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 2c5bd42e2..fda610981 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -80,6 +80,9 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Change board visibility - Update board visual and behavior settings - Set desktop and mobile home boards for the current user -- Configure global board defaults such as public home boards and status behavior +- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior +- Configure saved responsive layouts through Homarr's board settings UI when a server uses legacy mobile layout mode -Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. +Use the interactive API reference to inspect the exact request body for OpenAPI endpoints under the `boards` and +`settings` tags. Saved responsive layouts are intentionally not exposed as an OpenAPI or MCP replacement operation +because omitting a layout would remove it. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index a9e7bb187..e004d75bd 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -84,23 +84,29 @@ Homarr will fall back to the Homarr logo if nothing is set. ### Layout -In this section you can configure the number of columns in the desktop layout. +The available controls depend on the server-wide **Use automatic mobile layout** setting under `Management` > `Settings` > `Boards`. Items and sections can be moved and resized on desktop while the board is in edit mode. Changing the column count automatically adjusts item positions and sizes to fit the new grid. -#### Mobile layout +#### Automatic mobile layout -Phones use an automatic, read-only layout with exactly two columns in both portrait and landscape. Tablets use this layout at widths up to 48em. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high. +When automatic mobile layout is enabled, the board settings contain one desktop column-count control. Phones use an automatic, read-only layout with exactly two columns in both portrait and landscape. Tablets use this layout at widths up to 48em. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high. -Named categories and dynamic sections appear as lightweight headings and section shortcuts, but never as layout containers. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. Layout and widget configuration stay locked on mobile; links, refresh actions, automations, and widget details remain interactive. +Named categories and dynamic sections appear as lightweight headings and section shortcuts, but never as layout containers. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. Layout and item-configuration menus stay locked on mobile; links, refresh actions, automations, notebook content and checklists, and widget details remain interactive according to the user's normal permissions. App items use compact tiles. Widgets can opt into a compact summary, and larger widgets provide a full-screen detail view. Widgets farther down a long board are loaded shortly before they enter the viewport while their space is preserved with a skeleton. To rearrange, add, remove, resize, or configure board items, open the board on a larger screen. +#### Saved responsive layouts + +When automatic mobile layout is disabled, each board uses its saved responsive layouts. Board editors can add named layouts, choose their column counts and viewport breakpoints, and edit the selected layout directly on mobile. + +Enabling automatic mode does not delete these layouts. Homarr continues to maintain item and section placements for them, so an administrator can switch the server back to responsive mode later without losing the previous mobile arrangement. + ### Background #### Background image URL diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index d7705e61a..a727c717d 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -9,10 +9,12 @@ tags: --- # Settings + The settings page has a few options that will be applied to the entire Homarr application. They may require a full page reload for changes to apply. ## Analytics + By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements. The data is never shared with any third-parties and is fully GDPR & CCPA compliant. The software being used is [Umami Analytics](https://umami.is/) which is fully open source. @@ -21,25 +23,29 @@ The main server software can be found [here](https://github.com/umami-software/u A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions. We understand that you might not want Homarr to communicate with our services. -By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. -If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall. +By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. +If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall. Additionally, you opt in to more data collection to help us provide more useful data. ### Integration data + Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them. This will not include any URLs, names, secrets, errors and others. This is disabled by default. ### Widget data + Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them. This will not include any URLs, names, secrets, errors and others. This is disabled by default. ### Users data + Enabling this optional data will send the count of users and whether you activated SSO. This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs. This is disabled by default. ## Search Engine Optimization (SEO) + Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages. This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard. Homarr is requesting the crawlers to not index Homarr instances by default for this reason. @@ -47,22 +53,26 @@ If you use Homarr as a public website and want it to be searchable on these sear Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index. ### No index + This will ask crawlers to not index your Homarr instance on any indexes. It is up to the crawler to properly respect this request. By default, this setting is enabled. Disabling it will allow indexing. ### No follow + If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website. By default, this setting is enabled and will ask crawlers to not follow any links. Crawler of different companies may behave differently when indexing is prohibited and following is allowed. ### No translate + Google and Chrome on Android will prompt users to translate pages that are not in their device language. This can lead to some shifting and can be undesired in some cases. By enabling this, you ask crawlers to never translate and never prompt users for translation. It should be noted that some browsers will ignore this and translate regardless. ### No site links search box + This setting is exclusive to Google. [Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing". The user can search them directly in Google. @@ -71,8 +81,22 @@ This setting is disabled by default. ## Boards +### Automatic mobile layout + +**Use automatic mobile layout** controls mobile rendering for every board on the server: + +- Enabled: phones and small tablets use Homarr's read-only, two-column mobile view. Mobile layout editing is hidden. +- Disabled: boards use their saved responsive layouts and users with board modification access can edit layouts on mobile. + +This setting is enabled by default for new instances. During an upgrade, Homarr keeps it disabled when it detects existing custom responsive layouts, including boards with multiple layouts or a non-default saved layout. Instances that only contain the canonical desktop layout use the new automatic mode. Once an administrator saves an explicit choice, later upgrades preserve it. + +Saved responsive layouts are retained while automatic mode is enabled, so switching between the modes is reversible. Reload any board tabs that were already open when the setting changed. + +The automatic-layout setting is independent from the global mobile board below: one controls how a board is rendered, while the other controls which board opens on a detected mobile device. + ### Global home board -You can set a global home board that will be used for all users that have not set a home board themselves. + +You can set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. Note that the board must be public to be used as a global home board. @@ -97,9 +121,11 @@ You can decide if the app status should be disabled for all boards. ## Appearance ### Default color scheme + You can set the default color scheme for all users that have not set a color scheme themselves. ## Culture ### Default language -You can set the default language for all users that have not set a language themselves. \ No newline at end of file + +You can set the default language for all users that have not set a language themselves. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 5ec14adfa..c2ea55291 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -18,6 +18,8 @@ import { isNullOrWhitespace } from "@homarr/common"; import { userAgent } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import { db } from "@homarr/db"; +import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import type { WidgetKind } from "@homarr/definitions"; import { getQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; @@ -54,7 +56,12 @@ export const createBoardContentPage = >( const resolvedParams = await params; const queryClient = getQueryClient(); - const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]); + const [board, session, requestHeaders, boardSettings] = await Promise.all([ + getInitialBoard(resolvedParams), + auth(), + headers(), + getServerSettingByKeyAsync(db, "board"), + ]); const deviceType = userAgent(new Headers(requestHeaders)).device.type; const itemsMap = board.items.reduce((acc, item) => { @@ -70,7 +77,9 @@ export const createBoardContentPage = >( const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), ...Array.from(itemsMap) - .filter(([kind]) => shouldPrefetchWidgetForRequest(kind, deviceType)) + .filter(([kind]) => + shouldPrefetchWidgetForRequest(kind, deviceType, boardSettings.enableAutomaticMobileLayout), + ) .map(([kind, items]) => prefetchForKindAsync(kind, queryClient, items).catch((error) => { logger.error( diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 1c2ea43d0..56cf5d6e7 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core"; @@ -29,7 +29,8 @@ import { import { clientApi } from "@homarr/api/client"; import { signOut, useSession } from "@homarr/auth/client"; import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; -import { useEditMode } from "@homarr/boards/edit-mode"; +import type { BoardEditAction, BoardEditActionEvent } from "@homarr/boards/edit-mode"; +import { boardEditActionEventName, requestBoardEditAction, useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; @@ -61,6 +62,8 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool const isMobile = useIsMobileBoard(); const t = useI18n(); + usePreventLeaveWithDirty(isEditMode); + if (isMobile) { return ; } @@ -215,8 +218,6 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: }, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); - usePreventLeaveWithDirty(isEditMode); - if (hidden) return null; return ( @@ -269,6 +270,7 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { const tProfile = useScopedI18n("common.userAvatar.menu"); const reduceMotion = useReducedMotion(); const [opened, disclosure] = useDisclosure(false); + const [isSigningOut, setIsSigningOut] = useState(false); const moreTriggerRef = useRef(null); const pendingSectionAnchorId = useRef(null); const desktopLayout = getDesktopLayout(board); @@ -290,6 +292,26 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { moreTriggerRef.current?.focus({ preventScroll: true }); }; + const handleSignOut = useCallback(async () => { + setIsSigningOut(true); + + try { + const response = await signOut({ redirect: false }); + if (typeof response.url !== "string" || response.url.length === 0) { + throw new Error("Sign out did not return a success URL"); + } + window.location.replace(logoutUrl ?? "/auth/login"); + return true; + } catch { + setIsSigningOut(false); + showErrorNotification({ + title: tProfile("logoutError.title"), + message: tProfile("logoutError.message"), + }); + return false; + } + }, [logoutUrl, tProfile]); + return ( <> @@ -391,13 +413,8 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { size="lg" justify="flex-start" leftSection={} - onClick={() => { - void signOut({ redirect: false }) - .catch(() => undefined) - .then(() => { - window.location.assign(logoutUrl ?? "/auth/login"); - }); - }} + loading={isSigningOut} + onClick={() => requestBoardEditAction(handleSignOut)} > {tProfile("logout")} @@ -432,62 +449,207 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { ); }; -const anchorSelector = "a[href]:not([target='_blank'])"; +const anchorSelector = "a[href]:not([target='_blank']):not([download])"; const usePreventLeaveWithDirty = (isDirty: boolean) => { const t = useI18n(); const { openConfirmModal } = useConfirmModal(); const router = useRouter(); + const dependenciesRef = useRef({ openConfirmModal, router, t }); + + useEffect(() => { + dependenciesRef.current = { openConfirmModal, router, t }; + }, [openConfirmModal, router, t]); useEffect(() => { if (!isDirty) return; - const handleClick = (event: Event) => { - const target = (event.target as HTMLElement).closest("a"); + const guardStateKey = "__homarrBoardEditGuard"; + const guardId = `${Date.now()}-${Math.random()}`; + let guardedUrl = window.location.href; + let isLeaving = false; + let isConfirming = false; + let isActionPending = false; + let shouldRemoveGuardEntry = true; + let didPageHide = false; + let departureFallbackTimer: number | undefined; - if (!target) { - console.warn("No anchor element found for click event", event); - return; + const pushGuardEntry = () => { + window.history.pushState( + { + ...(window.history.state as Record | null), + [guardStateKey]: guardId, + }, + document.title, + guardedUrl, + ); + }; + + const rearmGuard = () => { + window.clearTimeout(departureFallbackTimer); + departureFallbackTimer = undefined; + isLeaving = false; + shouldRemoveGuardEntry = true; + + if ( + window.location.href === guardedUrl && + (window.history.state as Record | null)?.[guardStateKey] !== guardId + ) { + pushGuardEntry(); } + }; - event.preventDefault(); + const scheduleDepartureFallback = () => { + window.clearTimeout(departureFallbackTimer); + departureFallbackTimer = window.setTimeout(() => { + departureFallbackTimer = undefined; + if (!didPageHide) { + guardedUrl = window.location.href; + rearmGuard(); + } + }, 1_000); + }; - openConfirmModal({ - title: t("board.action.edit.confirmLeave.title"), - children: t("board.action.edit.confirmLeave.message"), - onConfirm() { - router.push(target.href); + const openLeaveConfirmation = (action: BoardEditAction) => { + if (isConfirming) return; + isConfirming = true; + + const dependencies = dependenciesRef.current; + dependencies.openConfirmModal({ + title: dependencies.t("board.action.edit.confirmLeave.title"), + children: dependencies.t("board.action.edit.confirmLeave.message"), + async onConfirm() { + isActionPending = true; + isLeaving = true; + shouldRemoveGuardEntry = false; + didPageHide = false; + + try { + const result = await action(); + if (result === false) { + rearmGuard(); + } else { + scheduleDepartureFallback(); + } + } catch { + rearmGuard(); + } finally { + isActionPending = false; + isConfirming = false; + } + }, + onClose() { + if (!isActionPending) { + isConfirming = false; + } }, confirmProps: { - children: t("common.action.discard"), + children: dependencies.t("common.action.discard"), }, }); }; - const handlePopState = (event: Event) => { - window.history.pushState(null, document.title, window.location.href); + pushGuardEntry(); + + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + !(event.target instanceof Element) + ) { + return; + } + + const target = event.target.closest(anchorSelector); + if (!target) return; + event.preventDefault(); + const destination = new URL(target.href, window.location.href); + + openLeaveConfirmation(() => { + if (destination.origin === window.location.origin) { + dependenciesRef.current.router.replace(`${destination.pathname}${destination.search}${destination.hash}`); + } else { + window.location.replace(destination.href); + } + return true; + }); + }; + + const handlePopState = () => { + if (isActionPending) { + if ( + window.location.href === guardedUrl && + (window.history.state as Record | null)?.[guardStateKey] !== guardId + ) { + pushGuardEntry(); + } + return; + } + if (isLeaving) return; + + pushGuardEntry(); + openLeaveConfirmation(() => { + window.history.go(-2); + return true; + }); + }; + + const handleBoardEditAction = (event: Event) => { + event.preventDefault(); + openLeaveConfirmation((event as BoardEditActionEvent).detail.action); + }; + + const handlePageShow = (event: PageTransitionEvent) => { + if (!event.persisted) return; + + didPageHide = false; + guardedUrl = window.location.href; + isLeaving = false; + isConfirming = false; + shouldRemoveGuardEntry = true; + if ((window.history.state as Record | null)?.[guardStateKey] !== guardId) { + pushGuardEntry(); + } + }; + + const handlePageHide = () => { + didPageHide = true; }; const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (env.NODE_ENV === "development") return; + if (isLeaving || env.NODE_ENV === "development") return; event.preventDefault(); event.returnValue = true; }; - const anchors = document.querySelectorAll(anchorSelector); - anchors.forEach((link) => { - link.addEventListener("click", handleClick); - }); + document.addEventListener("click", handleClick, true); + document.addEventListener(boardEditActionEventName, handleBoardEditAction); window.addEventListener("popstate", handlePopState); + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("pageshow", handlePageShow); window.addEventListener("beforeunload", handleBeforeUnload); return () => { - anchors.forEach((link) => { - link.removeEventListener("click", handleClick); - }); + document.removeEventListener("click", handleClick, true); + document.removeEventListener(boardEditActionEventName, handleBoardEditAction); window.removeEventListener("popstate", handlePopState); + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("pageshow", handlePageShow); window.removeEventListener("beforeunload", handleBeforeUnload); + window.clearTimeout(departureFallbackTimer); + + if ( + shouldRemoveGuardEntry && + window.location.href === guardedUrl && + (window.history.state as Record | null)?.[guardStateKey] === guardId + ) { + window.history.back(); + } }; }, [isDirty]); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts index 448119508..f0a978547 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts @@ -4,14 +4,18 @@ import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; describe("shouldPrefetchWidgetForRequest", () => { test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => { - expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false); + expect(shouldPrefetchWidgetForRequest("downloads", deviceType, true)).toBe(false); }); test("keeps downloads prefetched for desktop requests", () => { - expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true); + expect(shouldPrefetchWidgetForRequest("downloads", undefined, true)).toBe(true); + }); + + test("keeps downloads prefetched for legacy responsive mobile boards", () => { + expect(shouldPrefetchWidgetForRequest("downloads", "mobile", false)).toBe(true); }); test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => { - expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true); + expect(shouldPrefetchWidgetForRequest(kind, "mobile", true)).toBe(true); }); }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts index c5a89a754..b442f7cf5 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts @@ -1,7 +1,11 @@ import type { WidgetKind } from "@homarr/definitions"; -export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => { +export const shouldPrefetchWidgetForRequest = ( + kind: WidgetKind, + deviceType: string | undefined, + enableAutomaticMobileLayout: boolean, +) => { const isMobileDevice = deviceType === "mobile" || deviceType === "tablet"; - return kind !== "downloads" || !isMobileDevice; + return kind !== "downloads" || !isMobileDevice || !enableAutomaticMobileLayout; }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index ab7604fca..668dfa3e9 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -1,19 +1,42 @@ "use client"; -import { Button, Fieldset, Group, Input, Slider, Stack, Text } from "@mantine/core"; +import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { getDesktopLayout } from "@homarr/boards/context"; +import { createId } from "@homarr/common"; import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { boardSaveLayoutSchema } from "@homarr/validation/board"; +import { boardSaveLayoutSchema, boardSaveLayoutsSchema } from "@homarr/validation/board"; import type { Board } from "../../_types"; interface Props { board: Board; + enableAutomaticMobileLayout: boolean; } -export const LayoutSettingsContent = ({ board }: Props) => { + +const getNextAvailableBreakpoint = (layouts: Array<{ breakpoint: number }>) => { + const breakpoints = new Set(layouts.map((layout) => layout.breakpoint)); + const commonBreakpoints = [0, 480, 768, 1024, 1200, 1440, 1920]; + const commonBreakpoint = commonBreakpoints.find((breakpoint) => !breakpoints.has(breakpoint)); + if (commonBreakpoint !== undefined) return commonBreakpoint; + + for (let breakpoint = 0; breakpoint <= 32767; breakpoint++) { + if (!breakpoints.has(breakpoint)) return breakpoint; + } + + throw new Error("No responsive layout breakpoints are available"); +}; + +export const LayoutSettingsContent = ({ board, enableAutomaticMobileLayout }: Props) => + enableAutomaticMobileLayout ? ( + + ) : ( + + ); + +const AutomaticLayoutSettingsContent = ({ board }: Pick) => { const t = useI18n(); const utils = clientApi.useUtils(); const desktopLayout = getDesktopLayout(board); @@ -45,13 +68,20 @@ export const LayoutSettingsContent = ({ board }: Props) => { {t("board.setting.section.layout.desktop.description")} - + - {t("board.setting.section.layout.mobile.description")} + {t("board.setting.section.layout.mobile.automaticDescription")} @@ -63,3 +93,124 @@ export const LayoutSettingsContent = ({ board }: Props) => { ); }; + +const ResponsiveLayoutSettingsContent = ({ board }: Pick) => { + const t = useI18n(); + const utils = clientApi.useUtils(); + const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { + initialValues: { + layouts: board.layouts, + }, + }); + const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({ + onSuccess(layouts) { + const values = { layouts }; + form.setValues(values); + form.resetDirty(values); + }, + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }); + + return ( +
{ + saveLayouts({ + id: board.id, + ...values, + }); + })} + > + + + {t("board.setting.section.layout.responsive.description")} + + + + {t("board.setting.section.layout.responsive.title")} + + + + {form.values.layouts.map((layout, index) => ( +
+ + + + + + + + + + + + + + + + {form.values.layouts.length >= 2 && ( + + + + )} +
+ ))} +
+ + + + +
+
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index a728e8949..fd699b38c 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -90,7 +90,10 @@ export default async function BoardSettingsPage(props: Props) { - + diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx index 7b4551ff5..daccdcccf 100644 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -48,13 +48,22 @@ export const BoardSwitcherLogo = () => { {boards.map((availableBoard) => { const isCurrent = availableBoard.id === board.id; + if (isCurrent) { + return ( + } aria-current="page" disabled> + + {availableBoard.name} + + + ); + } + return ( : } - aria-current={isCurrent ? "page" : undefined} + leftSection={} > {availableBoard.name} diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2388e2812..5416f0d7c 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -107,6 +107,7 @@ export default async function Layout(props: { board: { homeBoardId: serverSettings.board.homeBoardId, mobileHomeBoardId: serverSettings.board.mobileHomeBoardId, + enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout, enableStatusByDefault: serverSettings.board.enableStatusByDefault, forceDisableStatus: serverSettings.board.forceDisableStatus, }, diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx index 615c6ba38..a7202e911 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -33,6 +33,13 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett {...form.getInputProps("mobileHomeBoardId")} /> + {tBoard("mobileLayout.title")} + + {tBoard("status.title")} ({ defaultValues: ServerSettings[TKey]; children: (form: ReturnType>) => React.ReactNode; }) => { + const router = useRouter(); const t = useI18n(); const tSettings = useScopedI18n("management.page.settings"); const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({ @@ -24,6 +26,7 @@ export const CommonSettingsForm = ({ showSuccessNotification({ message: tSettings("notification.success.message"), }); + router.refresh(); }, onError() { showErrorNotification({ diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index d4b2cb24d..9a0cf3d41 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -60,9 +60,15 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); + const layoutItemSize = boardLayout + ? { + ...itemSize, + width: Math.min(itemSize.width, boardLayout.columnCount), + } + : itemSize; const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) + ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -70,8 +76,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid } return { - width: itemSize.width, - height: itemSize.height, + width: layoutItemSize.width, + height: layoutItemSize.height, ...emptyPosition, sectionId: currentSection.id, layoutId, diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts index fd91c98d2..2f3875627 100644 --- a/apps/nextjs/src/components/board/items/actions/empty-position.ts +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -7,7 +7,7 @@ export const getFirstEmptyPosition = ( size: { width: number; height: number } = { width: 1, height: 1 }, ) => { for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) { - for (let xOffset = 0; xOffset < columnCount; xOffset++) { + for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) { const isOccupied = elements.some( (element) => element.yOffset < yOffset + size.height && diff --git a/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts b/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts index 0348b3f06..8b8f3a02f 100644 --- a/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts +++ b/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts @@ -1,9 +1,8 @@ -import { getCurrentLayout } from "@homarr/boards/context"; - import type { Board } from "~/app/[locale]/boards/_types"; export interface MoveAndResizeItemInput { itemId: string; + layoutId: string; xOffset: number; yOffset: number; width: number; @@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput { } export const moveAndResizeItemCallback = - ({ itemId, ...layoutInput }: MoveAndResizeItemInput) => + ({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) => (previous: Board): Board => { - const currentLayout = getCurrentLayout(previous); - return { ...previous, items: previous.items.map((item) => @@ -23,7 +20,7 @@ export const moveAndResizeItemCallback = : { ...item, layouts: item.layouts.map((layout) => - layout.layoutId !== currentLayout + layout.layoutId !== layoutId ? layout : { ...layout, diff --git a/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts b/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts index 98d093826..6b7f8d314 100644 --- a/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts +++ b/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts @@ -1,9 +1,8 @@ -import { getCurrentLayout } from "@homarr/boards/context"; - import type { Board } from "~/app/[locale]/boards/_types"; export interface MoveItemToSectionInput { itemId: string; + layoutId: string; sectionId: string; xOffset: number; yOffset: number; @@ -12,10 +11,8 @@ export interface MoveItemToSectionInput { } export const moveItemToSectionCallback = - ({ itemId, ...layoutInput }: MoveItemToSectionInput) => + ({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) => (board: Board): Board => { - const currentLayout = getCurrentLayout(board); - return { ...board, items: board.items.map((item) => @@ -24,7 +21,7 @@ export const moveItemToSectionCallback = : { ...item, layouts: item.layouts.map((layout) => - layout.layoutId !== currentLayout + layout.layoutId !== layoutId ? layout : { ...layout, diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index e2a0ff93c..bdef2d101 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as boardContext from "@homarr/boards/context"; @@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions create-item", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("should add it to first section", () => { // Arrange const itemKind = "clock"; @@ -108,4 +112,24 @@ describe("item actions create-item", () => { { height: 1, width: 1 }, ); }); + + test("clamps wide widgets to each retained layout", () => { + const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build(); + const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build(); + const board = new BoardMockBuilder() + .addLayout(desktopLayout) + .addLayout(mobileLayout) + .addEmptySection({ id: "root", yOffset: 0 }) + .build(); + + const result = createItemCallback({ kind: "mediaMissing" })(board); + const item = result.items.at(0); + + expect(item?.layouts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }), + expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }), + ]), + ); + }); }); diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index fcc194618..819af19f9 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -106,6 +106,10 @@ describe("get first empty position", () => { expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); }); + + test("does not place an item wider than the layout", () => { + expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined(); + }); }); const createElementsFromLayout = (layout: string[][]) => { diff --git a/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts index de3e5804b..d8c10cdd5 100644 --- a/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts @@ -1,6 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; - -import * as boardContext from "@homarr/boards/context"; +import { describe, expect, test } from "vitest"; import { moveAndResizeItemCallback } from "../move-and-resize-item"; import { BoardMockBuilder } from "./mocks/board-mock"; @@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => { // Arrange const itemToMove = "2"; const layoutId = "1"; - const spy = vi.spyOn(boardContext, "getCurrentLayout"); - spy.mockReturnValue(layoutId); const newPosition = { xOffset: 20, yOffset: 30, @@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => { const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build(); // Act - const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board); + const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(3); @@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => { // Arrange const itemToMove = "2"; const layoutId = "1"; - const spy = vi.spyOn(boardContext, "getCurrentLayout"); - spy.mockReturnValue(layoutId); const newPosition = { xOffset: 20, yOffset: 30, @@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => { const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build(); // Act - const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board); + const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(2); diff --git a/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts b/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts index 41e838ed2..5f930003a 100644 --- a/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts @@ -1,6 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; - -import * as boardContext from "@homarr/boards/context"; +import { describe, expect, test } from "vitest"; import { moveItemToSectionCallback } from "../move-item-to-section"; import { BoardMockBuilder } from "./mocks/board-mock"; @@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => { // Arrange const itemToMove = "2"; const layoutId = "1"; - const spy = vi.spyOn(boardContext, "getCurrentLayout"); - spy.mockReturnValue(layoutId); const newPosition = { sectionId: "3", xOffset: 20, @@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => { const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build(); // Act - const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board); + const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(3); @@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => { // Arrange const itemToMove = "2"; const layoutId = "1"; - const spy = vi.spyOn(boardContext, "getCurrentLayout"); - spy.mockReturnValue(layoutId); const newPosition = { sectionId: "3", xOffset: 20, @@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => { const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build(); // Act - const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board); + const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(2); diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index d217a8ec5..427784bf8 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,6 +1,6 @@ import type { MutableRefObject } from "react"; import { useRef } from "react"; -import { Badge, Card } from "@mantine/core"; +import { Badge, Card, Center, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; @@ -10,6 +10,8 @@ import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; +import { useI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import type { WidgetDisplayMode } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; @@ -26,7 +28,9 @@ interface BoardItemContentProps { displayMode?: WidgetDisplayMode; disableContextMenu?: boolean; isReadOnly?: boolean; + disableItemConfiguration?: boolean; widgetStateRef?: MutableRefObject | null>; + onOpenDetails?: () => void; } const getOverflowFromKind = (kind: SectionItem["kind"]) => { @@ -40,7 +44,9 @@ export const BoardItemContent = ({ displayMode = "default", disableContextMenu = false, isReadOnly = false, + disableItemConfiguration = false, widgetStateRef: externalWidgetStateRef, + onOpenDetails, }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); @@ -72,7 +78,9 @@ export const BoardItemContent = ({ height={height} displayMode={displayMode} isReadOnly={isReadOnly} + disableItemConfiguration={disableItemConfiguration} widgetStateRef={widgetStateRef} + onOpenDetails={onOpenDetails} /> ); @@ -116,13 +124,23 @@ interface InnerContentProps { height: number; displayMode: WidgetDisplayMode; isReadOnly: boolean; + disableItemConfiguration: boolean; widgetStateRef: MutableRefObject | null>; + onOpenDetails?: () => void; } -const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ + item, + displayMode, + isReadOnly, + disableItemConfiguration, + onOpenDetails, + ...dimensions +}: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); + const t = useI18n(); const Comp = loadWidgetDynamic(item.kind); const { definition } = widgetImports[item.kind]; const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); @@ -132,6 +150,14 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; + const hasNativeMobileSummary = + "mobile" in definition && + definition.mobile !== undefined && + "supportsCompactSummary" in definition.mobile && + definition.mobile.supportsCompactSummary === true; + const useGenericMobileSummary = displayMode === "mobileSummary" && !hasNativeMobileSummary; + const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); + const showItemMenu = !isReadOnly && !disableItemConfiguration; const queryClient = useQueryClient(); return ( @@ -144,7 +170,7 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - {!isReadOnly && } + {showItemMenu && } )} @@ -157,32 +183,79 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - {!isReadOnly && } - { - if (isReadOnly) return; - updateOptions({ - newOptions: { - ...options, - ...partialNewOptions.newOptions, - }, - }); - }} - displayMode={displayMode} - {...dimensions} - /> + {showItemMenu && } + {useGenericMobileSummary ? ( + + ) : ( + { + if (isReadOnly) return; + updateOptions({ + newOptions: { + ...options, + ...partialNewOptions.newOptions, + }, + }); + }} + displayMode={displayMode} + {...dimensions} + /> + )} )} ); }; +const GenericMobileSummary = ({ + icon: Icon, + title, + widgetName, + detailsLabel, + onOpenDetails, +}: { + icon: TablerIcon; + title: string; + widgetName: string; + detailsLabel: string; + onOpenDetails?: () => void; +}) => ( + +
+ + + + + + {widgetName} + + + {detailsLabel} + + +
+
+); + const Throw = ({ when, error }: { when: boolean; error: Error }) => { if (when) throw error; return null; diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx index de0fa9d98..b66894c39 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -5,66 +5,56 @@ import { useEffect, useRef, useState } from "react"; import { Skeleton } from "@mantine/core"; import classes from "./mobile-board.module.css"; +import { observeMobileViewportProximity } from "./mobile-viewport-observer"; interface DeferredMobileItemProps extends PropsWithChildren { eager: boolean; unmountWhenOffscreen: boolean; + onNearViewportChange?: (isNearViewport: boolean) => void; } -export const mobilePreloadViewportMultiplier = 2; +export { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./mobile-viewport-observer"; -export const getMobilePreloadRootMargin = (viewportHeight: number) => - `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; - -export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => { +export const DeferredMobileItem = ({ + eager, + unmountWhenOffscreen, + onNearViewportChange, + children, +}: DeferredMobileItemProps) => { const [hasMounted, setHasMounted] = useState(eager); const wrapperRef = useRef(null); - const hasMountedOnceRef = useRef(eager); useEffect(() => { if (eager) { - hasMountedOnceRef.current = true; setHasMounted(true); + onNearViewportChange?.(true); return; } const element = wrapperRef.current; - if (!element || typeof IntersectionObserver === "undefined") { + if (!element) { setHasMounted(true); + onNearViewportChange?.(true); return; } - let observer: IntersectionObserver | null = null; - const observe = () => { - if (hasMountedOnceRef.current && !unmountWhenOffscreen) return; - observer?.disconnect(); - observer = new IntersectionObserver( - ([entry]) => { - const isNearViewport = entry?.isIntersecting ?? false; + return observeMobileViewportProximity( + element, + (isNearViewport) => { + onNearViewportChange?.(isNearViewport); - if (isNearViewport) { - hasMountedOnceRef.current = true; - setHasMounted(true); - if (!unmountWhenOffscreen) observer?.disconnect(); - return; - } + if (isNearViewport) { + setHasMounted(true); + return; + } - if (unmountWhenOffscreen && !element.contains(document.activeElement)) { - setHasMounted(false); - } - }, - { rootMargin: getMobilePreloadRootMargin(window.innerHeight) }, - ); - observer.observe(element); - }; - - observe(); - window.addEventListener("resize", observe); - return () => { - window.removeEventListener("resize", observe); - observer?.disconnect(); - }; - }, [eager, unmountWhenOffscreen]); + if (unmountWhenOffscreen && !element.contains(document.activeElement)) { + setHasMounted(false); + } + }, + { once: !unmountWhenOffscreen }, + ); + }, [eager, onNearViewportChange, unmountWhenOffscreen]); return (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 6f67e977f..58940420f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -1,8 +1,9 @@ "use client"; import type { CSSProperties, RefObject } from "react"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { IconLayoutBoard } from "@tabler/icons-react"; import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; @@ -63,6 +64,8 @@ const getMobilePresentation = (item: SectionItem) => { const MobileBoardItem = ({ item }: { item: SectionItem }) => { const widgetStateRef = useRef | null>(null); const presentation = getMobilePresentation(item); + const [isNearViewport, setIsNearViewport] = useState(presentation.eager); + const [detailsOpened, detailsDisclosure] = useDisclosure(false); return ( { data-mobile-board-item={item.id} data-mobile-display-mode={presentation.displayMode} > - + - + ); }; diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index a145bc20b..45e59a506 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { isMobileContextActionVisible, resolveMobileItemPresentation, + shouldKeepMobileWidgetActionsMounted, shouldRenderMobileWidgetActions, } from "./mobile-presentation"; @@ -42,7 +43,7 @@ describe("resolveMobileItemPresentation", () => { }); }); - test("keeps automatic footprint defaults when only details are configured", () => { + test("uses a compact one-row fallback when only details are configured", () => { expect( resolveMobileItemPresentation( { kind: "calendar", height: 7 }, @@ -52,8 +53,10 @@ describe("resolveMobileItemPresentation", () => { ), ).toMatchObject({ width: 2, - height: 3, + height: 1, + displayMode: "mobileSummary", supportsDetails: true, + usesGenericSummary: true, }); }); @@ -75,6 +78,7 @@ describe("resolveMobileItemPresentation", () => { height: 1, displayMode: "mobileSummary", supportsDetails: false, + usesGenericSummary: false, eager: true, unmountWhenOffscreen: false, }); @@ -101,6 +105,42 @@ describe("shouldRenderMobileWidgetActions", () => { }); }); +describe("shouldKeepMobileWidgetActionsMounted", () => { + test("does not expose actions before a deferred widget is near the viewport", () => { + expect( + shouldKeepMobileWidgetActionsMounted({ + isNearViewport: false, + actionsOpened: false, + detailsOpened: false, + isOpeningDetails: false, + isCompletingAction: false, + actionTriggerHasFocus: false, + }), + ).toBe(false); + }); + + test.each([ + ["near the viewport", { isNearViewport: true }], + ["showing its action drawer", { actionsOpened: true }], + ["showing its detail view", { detailsOpened: true }], + ["transitioning to details", { isOpeningDetails: true }], + ["completing an action", { isCompletingAction: true }], + ["retaining keyboard focus", { actionTriggerHasFocus: true }], + ])("keeps actions mounted while %s", (_label, activeState) => { + expect( + shouldKeepMobileWidgetActionsMounted({ + isNearViewport: false, + actionsOpened: false, + detailsOpened: false, + isOpeningDetails: false, + isCompletingAction: false, + actionTriggerHasFocus: false, + ...activeState, + }), + ).toBe(true); + }); +}); + describe("isMobileContextActionVisible", () => { test("requires actions to opt into the read-only mobile surface", () => { const action = { key: "configure", label: "Configure", onClick: () => undefined }; diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index 41917e4a1..08125d5bf 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -4,14 +4,21 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/ export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, mobile: WidgetMobilePresentation | undefined, -) => ({ - width: mobile?.width ?? (item.kind === "app" ? 1 : 2), - height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), - displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), - supportsDetails: mobile?.supportsDetailView === true, - eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), - unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), -}); +) => { + const supportsCompactSummary = mobile?.supportsCompactSummary === true; + const supportsDetails = mobile?.supportsDetailView === true; + const usesGenericSummary = supportsDetails && !supportsCompactSummary; + + return { + width: mobile?.width ?? (item.kind === "app" ? 1 : 2), + height: mobile?.height ?? (item.kind === "app" || usesGenericSummary ? 1 : Math.max(1, Math.min(item.height, 3))), + displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const), + supportsDetails, + usesGenericSummary, + eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), + unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), + }; +}; export const shouldRenderMobileWidgetActions = ({ supportsDetails, @@ -23,5 +30,24 @@ export const shouldRenderMobileWidgetActions = ({ visibleContextActionCount: number; }) => supportsDetails || supportsRefresh || visibleContextActionCount > 0; +interface ShouldKeepMobileWidgetActionsMountedInput { + isNearViewport: boolean; + actionsOpened: boolean; + detailsOpened: boolean; + isOpeningDetails: boolean; + isCompletingAction: boolean; + actionTriggerHasFocus: boolean; +} + +export const shouldKeepMobileWidgetActionsMounted = ({ + isNearViewport, + actionsOpened, + detailsOpened, + isOpeningDetails, + isCompletingAction, + actionTriggerHasFocus, +}: ShouldKeepMobileWidgetActionsMountedInput) => + isNearViewport || actionsOpened || detailsOpened || isOpeningDetails || isCompletingAction || actionTriggerHasFocus; + export const isMobileContextActionVisible = (action: WidgetContextMenuAction) => action.mobileVisible === true && !action.hidden; diff --git a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts new file mode 100644 index 000000000..6ad42dd38 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { getMobilePreloadRootMargin, observeMobileViewportProximity } from "./mobile-viewport-observer"; + +class IntersectionObserverMock { + static instances: IntersectionObserverMock[] = []; + + readonly observedElements = new Set(); + readonly rootMargin: string; + disconnected = false; + + constructor( + private readonly callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + this.rootMargin = options?.rootMargin ?? "0px"; + IntersectionObserverMock.instances.push(this); + } + + observe(element: Element) { + this.observedElements.add(element); + } + + unobserve(element: Element) { + this.observedElements.delete(element); + } + + disconnect() { + this.disconnected = true; + this.observedElements.clear(); + } + + emit(element: Element, isIntersecting: boolean) { + this.callback([{ target: element, isIntersecting } as IntersectionObserverEntry], this as never); + } +} + +describe("observeMobileViewportProximity", () => { + beforeEach(() => { + IntersectionObserverMock.instances = []; + vi.stubGlobal("IntersectionObserver", IntersectionObserverMock); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 600, writable: true }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("shares one observer across cards and recreates it once when the viewport margin changes", () => { + const firstElement = document.createElement("div"); + const secondElement = document.createElement("div"); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + const stopObservingFirst = observeMobileViewportProximity(firstElement, firstListener); + const stopObservingSecond = observeMobileViewportProximity(secondElement, secondListener); + + expect(IntersectionObserverMock.instances).toHaveLength(1); + expect(IntersectionObserverMock.instances[0]?.observedElements).toEqual(new Set([firstElement, secondElement])); + expect(IntersectionObserverMock.instances[0]?.rootMargin).toBe(getMobilePreloadRootMargin(600)); + + IntersectionObserverMock.instances[0]?.emit(firstElement, true); + expect(firstListener).toHaveBeenCalledWith(true); + expect(secondListener).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event("resize")); + expect(IntersectionObserverMock.instances).toHaveLength(1); + + window.innerHeight = 700; + window.dispatchEvent(new Event("resize")); + + expect(IntersectionObserverMock.instances).toHaveLength(2); + expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true); + expect(IntersectionObserverMock.instances[1]?.observedElements).toEqual(new Set([firstElement, secondElement])); + expect(IntersectionObserverMock.instances[1]?.rootMargin).toBe(getMobilePreloadRootMargin(700)); + + stopObservingFirst(); + stopObservingSecond(); + expect(IntersectionObserverMock.instances[1]?.disconnected).toBe(true); + }); + + test("removes one-time subscriptions after their card first becomes near", () => { + const element = document.createElement("div"); + const listener = vi.fn(); + + const stopObserving = observeMobileViewportProximity(element, listener, { once: true }); + IntersectionObserverMock.instances[0]?.emit(element, true); + + expect(listener).toHaveBeenCalledOnce(); + expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true); + stopObserving(); + }); + + test("mounts content immediately when IntersectionObserver is unavailable", () => { + vi.stubGlobal("IntersectionObserver", undefined); + const listener = vi.fn(); + + const stopObserving = observeMobileViewportProximity(document.createElement("div"), listener); + + expect(listener).toHaveBeenCalledWith(true); + stopObserving(); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts new file mode 100644 index 000000000..61c657269 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts @@ -0,0 +1,116 @@ +export const mobilePreloadViewportMultiplier = 2; + +export const getMobilePreloadRootMargin = (viewportHeight: number) => + `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; + +interface MobileViewportSubscription { + listener: (isNearViewport: boolean) => void; + once: boolean; +} + +interface ObserveMobileViewportOptions { + once?: boolean; +} + +const subscriptions = new Map>(); + +let observer: IntersectionObserver | null = null; +let observedRootMargin: string | null = null; +let isListeningForResize = false; + +const stopObservingIfIdle = () => { + if (subscriptions.size > 0) return; + + observer?.disconnect(); + observer = null; + observedRootMargin = null; + + if (isListeningForResize) { + window.removeEventListener("resize", handleViewportResize); + isListeningForResize = false; + } +}; + +const removeSubscription = (element: Element, subscription: MobileViewportSubscription) => { + const elementSubscriptions = subscriptions.get(element); + if (!elementSubscriptions) return; + + elementSubscriptions.delete(subscription); + if (elementSubscriptions.size > 0) return; + + subscriptions.delete(element); + observer?.unobserve(element); + stopObservingIfIdle(); +}; + +const handleIntersections: IntersectionObserverCallback = (entries) => { + for (const entry of entries) { + const elementSubscriptions = subscriptions.get(entry.target); + if (!elementSubscriptions) continue; + + for (const subscription of elementSubscriptions) { + subscription.listener(entry.isIntersecting); + if (entry.isIntersecting && subscription.once) { + removeSubscription(entry.target, subscription); + } + } + } +}; + +const createObserver = (rootMargin: string) => { + observer?.disconnect(); + observer = new IntersectionObserver(handleIntersections, { rootMargin }); + observedRootMargin = rootMargin; + + for (const element of subscriptions.keys()) { + observer.observe(element); + } +}; + +function handleViewportResize() { + const rootMargin = getMobilePreloadRootMargin(window.innerHeight); + if (rootMargin === observedRootMargin) return; + + createObserver(rootMargin); +} + +const ensureObserver = () => { + if (observer) return; + + createObserver(getMobilePreloadRootMargin(window.innerHeight)); + window.addEventListener("resize", handleViewportResize); + isListeningForResize = true; +}; + +export const observeMobileViewportProximity = ( + element: Element, + listener: (isNearViewport: boolean) => void, + options: ObserveMobileViewportOptions = {}, +) => { + if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") { + listener(true); + return () => undefined; + } + + const subscription: MobileViewportSubscription = { + listener, + once: options.once ?? false, + }; + const elementSubscriptions = subscriptions.get(element); + + if (elementSubscriptions) { + elementSubscriptions.add(subscription); + } else { + subscriptions.set(element, new Set([subscription])); + } + + if (observer) { + observer.observe(element); + } else { + ensureObserver(); + } + + return () => { + removeSubscription(element, subscription); + }; +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx index 60f50c234..312a60945 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject } from "react"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; @@ -20,20 +20,40 @@ import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; import { useHasNestedDialog } from "./mobile-dialog-stack"; import classes from "./mobile-board.module.css"; -import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation"; +import { + isMobileContextActionVisible, + shouldKeepMobileWidgetActionsMounted, + shouldRenderMobileWidgetActions, +} from "./mobile-presentation"; interface MobileWidgetActionsProps { item: SectionItem; supportsDetails: boolean; + detailsFromCard: boolean; + detailsOpened: boolean; + openDetails: () => void; + closeDetails: () => void; widgetStateRef: MutableRefObject | null>; + isNearViewport: boolean; } -export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { +export const MobileWidgetActions = ({ + item, + supportsDetails, + detailsFromCard, + detailsOpened, + openDetails, + closeDetails, + widgetStateRef, + isNearViewport, +}: MobileWidgetActionsProps) => { const [actionsOpened, actionsDisclosure] = useDisclosure(false); - const [detailsOpened, detailsDisclosure] = useDisclosure(false); + const [isOpeningDetails, setIsOpeningDetails] = useState(false); + const [isCompletingAction, setIsCompletingAction] = useState(false); + const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false); const actionTriggerRef = useRef(null); const detailsDialogRef = useRef(null); - const openDetailsAfterActionsRef = useRef(false); + const pendingContextActionRef = useRef<(() => void) | null>(null); const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef); const t = useI18n(); const settings = useSettings(); @@ -59,133 +79,169 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M const visibleContextActions = contextActions.filter(isMobileContextActionVisible); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); const hasActions = shouldRenderMobileWidgetActions({ - supportsDetails, + supportsDetails: supportsDetails && !detailsFromCard, supportsRefresh: queryKey !== undefined, visibleContextActionCount: visibleContextActions.length, }); + const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({ + isNearViewport, + actionsOpened, + detailsOpened, + isOpeningDetails, + isCompletingAction, + actionTriggerHasFocus, + }); - if (!hasActions) return null; + if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null; return ( <> - - - + {hasActions && ( + <> + setActionTriggerHasFocus(true)} + onBlur={() => setActionTriggerHasFocus(false)} + > + + - { - if (!openDetailsAfterActionsRef.current) return; - openDetailsAfterActionsRef.current = false; - detailsDisclosure.open(); - }} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} - > - - {supportsDetails && ( - - )} - {visibleContextActions.map((action) => { - const Icon = action.icon; - return ( - - ); - })} - {queryKey && } - - + { + if (isOpeningDetails) { + setIsOpeningDetails(false); + openDetails(); + return; + } - actionTriggerRef.current?.focus()} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} - styles={{ - content: { - display: "flex", - flexDirection: "column", - height: "100dvh", - paddingTop: "env(safe-area-inset-top)", - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, - body: { - flex: "1 1 auto", - minHeight: 0, - overflowY: "auto", - paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", - }, - }} - > - - - - + const pendingContextAction = pendingContextActionRef.current; + if (pendingContextAction) { + pendingContextActionRef.current = null; + setIsCompletingAction(false); + pendingContextAction(); + return; + } + + actionTriggerRef.current?.focus(); + }} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} + > + + {supportsDetails && !detailsFromCard && ( + + )} + {visibleContextActions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} + {queryKey && } + + + + )} + + {supportsDetails && ( + { + if (!detailsFromCard) { + actionTriggerRef.current?.focus(); + } + }} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} + styles={{ + content: { + display: "flex", + flexDirection: "column", + height: "100dvh", + paddingTop: "env(safe-area-inset-top)", + paddingLeft: "env(safe-area-inset-left)", + paddingRight: "env(safe-area-inset-right)", + }, + body: { + flex: "1 1 auto", + minHeight: 0, + overflowY: "auto", + paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", + }, + }} + > + + + + + )} ); }; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 906abdea6..7a17620ea 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { fetchApi } from "@homarr/api/client"; -import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; @@ -18,6 +18,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions(); const t = useI18n(); const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); const createCategoryAtYOffset = useCallback( (position: number) => { @@ -104,7 +105,6 @@ export const useCategoryMenuActions = (category: CategorySection) => { const settings = useSettings(); const openAllInNewTabs = useCallback(async () => { - const currentLayoutId = getCurrentLayout(board); const appIds = filterByItemKind( board.items.filter( (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, @@ -130,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { }); break; } - }, [category, board, t, openConfirmModal, settings]); + }, [category, board, currentLayoutId, t, openConfirmModal, settings]); return { addCategoryAbove, diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/dynamic-section.tsx index 8bb0a52c3..c599f607e 100644 --- a/apps/nextjs/src/components/board/sections/dynamic-section.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic-section.tsx @@ -1,7 +1,7 @@ import { Badge, Box, Card } from "@mantine/core"; import combineClasses from "clsx"; -import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useRequiredBoard } from "@homarr/boards/context"; import type { DynamicSectionItem } from "~/app/[locale]/boards/_types"; import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu"; @@ -14,7 +14,6 @@ interface Props { export const BoardDynamicSection = ({ section }: Props) => { const board = useRequiredBoard(); - const currentLayoutId = useCurrentLayout(); const options = section.options; return ( @@ -52,8 +51,7 @@ export const BoardDynamicSection = ({ section }: Props) => { {options.title} )} - {/* Use unique key by layout to reinitialize gridstack */} - + diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx index e851e5c2a..1688f6544 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx @@ -4,6 +4,8 @@ import type { BoxProps } from "@mantine/core"; import { Box } from "@mantine/core"; import combineClasses from "clsx"; +import { useCurrentLayout } from "@homarr/boards/context"; + import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types"; import { SectionContent } from "../content"; import { SectionProvider } from "../section-context"; @@ -15,6 +17,12 @@ interface Props extends BoxProps { } export const GridStack = ({ section, ...props }: Props) => { + const currentLayoutId = useCurrentLayout(); + + return ; +}; + +const GridStackForLayout = ({ section, ...props }: Props) => { const { items, innerSections } = useSectionItems(section.id); const itemIds = [...items, ...innerSections].map((item) => item.id); diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index b5e468de6..4b3c7d33f 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -103,6 +103,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) // Updates the react-query state moveAndResizeItem({ itemId: id, + layoutId: currentLayoutId, // We want the following properties to be null by default // so the next free position is used from the gridstack // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -120,6 +121,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) if (type === "section") { moveAndResizeInnerSection({ innerSectionId: id, + layoutId: currentLayoutId, // We want the following properties to be null by default // so the next free position is used from the gridstack // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -136,7 +138,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`); }, - [moveAndResizeItem, moveAndResizeInnerSection], + [currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection], ); const onAdd = useCallback( (addedNode: GridStackNode) => { @@ -149,6 +151,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) // Updates the react-query state moveItemToSection({ itemId: id, + layoutId: currentLayoutId, sectionId: section.id, // We want the following properties to be null by default // so the next free position is used from the gridstack @@ -167,6 +170,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) if (type === "section") { moveInnerSectionToSection({ innerSectionId: id, + layoutId: currentLayoutId, sectionId: section.id, // We want the following properties to be null by default // so the next free position is used from the gridstack @@ -184,7 +188,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`); }, - [moveItemToSection, moveInnerSectionToSection, section.id], + [currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id], ); // initialize the gridstack diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx index f61f51552..3db4a7924 100644 --- a/apps/nextjs/src/components/board/sections/section-actions.tsx +++ b/apps/nextjs/src/components/board/sections/section-actions.tsx @@ -1,10 +1,10 @@ import { useCallback } from "react"; -import { getCurrentLayout } from "@homarr/boards/context"; import { useUpdateBoard } from "@homarr/boards/updater"; interface MoveAndResizeInnerSection { innerSectionId: string; + layoutId: string; xOffset: number; yOffset: number; width: number; @@ -12,6 +12,7 @@ interface MoveAndResizeInnerSection { } interface MoveInnerSectionToSection { innerSectionId: string; + layoutId: string; sectionId: string; xOffset: number; yOffset: number; @@ -23,7 +24,7 @@ export const useSectionActions = () => { const { updateBoard } = useUpdateBoard(); const moveAndResizeInnerSection = useCallback( - ({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => { + ({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => { updateBoard((previous) => ({ ...previous, sections: previous.sections.map((section) => { @@ -31,12 +32,10 @@ export const useSectionActions = () => { if (section.id !== innerSectionId) return section; if (section.kind !== "dynamic") return section; - const currentLayout = getCurrentLayout(previous); - return { ...section, layouts: section.layouts.map((layout) => { - if (layout.layoutId !== currentLayout) return layout; + if (layout.layoutId !== layoutId) return layout; return { ...layout, ...positionProps, @@ -50,7 +49,7 @@ export const useSectionActions = () => { ); const moveInnerSectionToSection = useCallback( - ({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => { + ({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => { updateBoard((previous) => { return { ...previous, @@ -59,12 +58,10 @@ export const useSectionActions = () => { if (section.id !== innerSectionId) return section; if (section.kind !== "dynamic") return section; - const currentLayout = getCurrentLayout(previous); - return { ...section, layouts: section.layouts.map((layout) => { - if (layout.layoutId !== currentLayout) return layout; + if (layout.layoutId !== layoutId) return layout; return { ...layout, ...positionProps, diff --git a/apps/nextjs/src/components/board/use-mobile-board.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts index 35a8a95b6..df8827ac5 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { resolveMobileBoardViewport } from "./use-mobile-board"; +import { + resolveIsAutomaticMobileBoard, + resolveMobileBoardViewport, + shouldShowMobileBoardViewportSkeleton, +} from "./use-mobile-board"; describe("resolveMobileBoardViewport", () => { test("keeps phones mobile regardless of their viewport width", () => { @@ -56,3 +60,26 @@ describe("resolveMobileBoardViewport", () => { }); }); }); + +describe("resolveIsAutomaticMobileBoard", () => { + test.each([ + [true, true, true], + [true, false, false], + [false, true, false], + [false, false, false], + ])( + "requires both a mobile viewport and the server setting (viewport: %s, setting: %s)", + (isMobileViewport, enableAutomaticMobileLayout, expected) => { + expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected); + }, + ); + + test("does not show the tablet resolution skeleton in legacy mode", () => { + expect( + shouldShowMobileBoardViewportSkeleton({ + enableAutomaticMobileLayout: false, + isResolved: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 7197aac22..1972206f2 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -5,6 +5,8 @@ import { createContext, useContext, useEffect, useState } from "react"; import { Box, Group, Skeleton, Stack } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; +import { useSettings } from "@homarr/settings"; + export const mobileBoardMediaQuery = "(max-width: 48em)"; export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop"; @@ -70,6 +72,7 @@ export const MobileBoardViewportProvider = ({ initialDeviceClass, children, }: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { + const { enableAutomaticMobileLayout } = useSettings(); const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { getInitialValueInEffect: true, @@ -87,11 +90,42 @@ export const MobileBoardViewportProvider = ({ return ( - {viewport.isResolved ? children : } + {shouldShowMobileBoardViewportSkeleton({ + enableAutomaticMobileLayout, + isResolved: viewport.isResolved, + }) ? ( + + ) : ( + children + )} ); }; export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext); -export const useIsMobileBoard = () => useMobileBoardViewport().isMobile; +export const resolveIsAutomaticMobileBoard = ({ + isMobileViewport, + enableAutomaticMobileLayout, +}: { + isMobileViewport: boolean; + enableAutomaticMobileLayout: boolean; +}) => isMobileViewport && enableAutomaticMobileLayout; + +export const shouldShowMobileBoardViewportSkeleton = ({ + enableAutomaticMobileLayout, + isResolved, +}: { + enableAutomaticMobileLayout: boolean; + isResolved: boolean; +}) => enableAutomaticMobileLayout && !isResolved; + +export const useIsMobileBoard = () => { + const viewport = useMobileBoardViewport(); + const { enableAutomaticMobileLayout } = useSettings(); + + return resolveIsAutomaticMobileBoard({ + isMobileViewport: viewport.isMobile, + enableAutomaticMobileLayout, + }); +}; diff --git a/apps/nextjs/src/components/language/current-language-combobox.tsx b/apps/nextjs/src/components/language/current-language-combobox.tsx index 54d9a0f98..9a8dbcb52 100644 --- a/apps/nextjs/src/components/language/current-language-combobox.tsx +++ b/apps/nextjs/src/components/language/current-language-combobox.tsx @@ -1,5 +1,6 @@ "use client"; +import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client"; import { LanguageCombobox } from "./language-combobox"; @@ -16,7 +17,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage return ( requestBoardEditAction(() => changeLocale(locale))} isPending={isPending} width={width} withinPortal={withinPortal} diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index cf6d013a2..2201a658f 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -17,8 +17,10 @@ import { import type { RouterOutputs } from "@homarr/api"; import { signOut, useSession } from "@homarr/auth/client"; +import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import { hotkeys } from "@homarr/definitions"; import { createModal, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -47,22 +49,36 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const handleSignout = useCallback(async () => { - await signOut({ - redirect: false, - }); - openModal({ - onTimeout: () => { - if (logoutUrl) { - window.location.assign(logoutUrl); - return; - } - router.push("/auth/login"); - }, - }); - }, [logoutUrl, openModal, router]); + try { + const response = await signOut({ + redirect: false, + }); + if (typeof response.url !== "string" || response.url.length === 0) { + throw new Error("Sign out did not return a success URL"); + } + + return await new Promise((resolve) => { + openModal({ + onTimeout: () => { + resolve(true); + if (logoutUrl) { + window.location.replace(logoutUrl); + return; + } + router.replace("/auth/login"); + }, + }); + }); + } catch { + showErrorNotification({ + title: t("logoutError.title"), + message: t("logoutError.message"), + }); + return false; + } + }, [logoutUrl, openModal, router, t]); return ( - // We use keepMounted so we can add event listeners to prevent navigating away without saving the board @@ -100,7 +116,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab )} {session.status === "authenticated" ? ( - } color="red"> + requestBoardEditAction(handleSignout)} + leftSection={} + color="red" + > {t("logout")} ) : ( diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index 959c57065..6c929b16e 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -1,6 +1,7 @@ +import type { Page } from "@playwright/test"; import { chromium, devices, expect } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; -import SuperJSON from "superjson"; +import { stringify } from "superjson"; import { describe, test } from "vitest"; import { eq } from "drizzle-orm"; @@ -18,9 +19,34 @@ const adminCredentials = { const boardName = "mobile-e2e"; +const setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => { + await page.goto(new URL("/manage/settings", page.url()).toString()); + const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" }); + await expect(toggle).toBeVisible(); + + if ((await toggle.isChecked()) !== enabled) { + if (enabled) { + await toggle.check(); + } else { + await toggle.uncheck(); + } + + await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Settings saved successfully")).toBeVisible(); + } + + await page.reload(); + if (enabled) { + await expect(page.getByRole("switch", { name: "Use automatic mobile layout" })).toBeChecked(); + } else { + await expect(page.getByRole("switch", { name: "Use automatic mobile layout" })).not.toBeChecked(); + } +}; + const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { const boardId = createId(); - const layoutId = createId(); + const desktopLayoutId = createId(); + const mobileLayoutId = createId(); const firstSectionId = createId(); const secondSectionId = createId(); @@ -29,13 +55,22 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { name: boardName, creatorId: userId, }); - await db.insert(sqliteSchema.layouts).values({ - id: layoutId, - name: "Desktop", - boardId, - columnCount: 12, - breakpoint: 0, - }); + await db.insert(sqliteSchema.layouts).values([ + { + id: desktopLayoutId, + name: "Desktop", + boardId, + columnCount: 12, + breakpoint: 1200, + }, + { + id: mobileLayoutId, + name: "Mobile", + boardId, + columnCount: 4, + breakpoint: 0, + }, + ]); await db.insert(sqliteSchema.sections).values([ { id: firstSectionId, @@ -60,22 +95,33 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { return { id, boardId, - kind: index === 0 ? ("notebook" as const) : ("clock" as const), - options: SuperJSON.stringify(index === 0 ? { content: "

Mobile notes

" } : {}), - advancedOptions: SuperJSON.stringify({}), + kind: index === 0 ? ("notebook" as const) : index === 1 ? ("rssFeed" as const) : ("clock" as const), + options: stringify(index === 0 ? { content: "

Mobile notes

" } : {}), + advancedOptions: stringify({}), }; }); await db.insert(sqliteSchema.items).values(itemRows); await db.insert(sqliteSchema.itemLayouts).values( - itemRows.map((item, index) => ({ - itemId: item.id, - layoutId, - sectionId: index < 18 ? firstSectionId : secondSectionId, - xOffset: (index % 6) * 2, - yOffset: Math.floor((index % 18) / 6) * 2, - width: 2, - height: 2, - })), + itemRows.flatMap((item, index) => [ + { + itemId: item.id, + layoutId: desktopLayoutId, + sectionId: index < 18 ? firstSectionId : secondSectionId, + xOffset: (index % 6) * 2, + yOffset: Math.floor((index % 18) / 6) * 2, + width: 2, + height: 2, + }, + { + itemId: item.id, + layoutId: mobileLayoutId, + sectionId: index < 18 ? firstSectionId : secondSectionId, + xOffset: (index % 2) * 2, + yOffset: Math.floor((index % 18) / 2) * 2, + width: 2, + height: 2, + }, + ]), ); await db .update(sqliteSchema.users) @@ -112,6 +158,7 @@ describe("Automatic mobile board", () => { await loginPage.locator("#password").fill(adminCredentials.password); await loginPage.locator("button[type='submit']").click(); await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); + await setAutomaticMobileLayoutAsync(loginPage, true); const storageState = await loginContext.storageState(); const mobileViewports = [ @@ -150,6 +197,7 @@ describe("Automatic mobile board", () => { await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36); await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0); await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible(); + await expect(page.getByRole("button", { name: "Edit item", exact: true }), viewport.name).toHaveCount(0); const layoutMetrics = await page.evaluate(() => { const grids = Array.from(document.querySelectorAll("[data-mobile-board] > div")); @@ -201,6 +249,10 @@ describe("Automatic mobile board", () => { } if (viewport.width === 390) { + await page.getByRole("button", { name: `Current board: ${boardName}` }).click(); + await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "More" }).click(); const moreDrawer = page.getByRole("dialog", { name: "More" }); await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible(); @@ -209,12 +261,20 @@ describe("Automatic mobile board", () => { const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i }); await notebookActions.click(); - await page.getByRole("button", { name: "Open widget details" }).click(); + await page.getByRole("button", { name: "Open widget details", exact: true }).click(); const details = page.getByRole("dialog", { name: "Notebook" }); await expect(details).toBeVisible(); + await expect(details.getByRole("button", { name: "Edit" })).toBeVisible(); await details.getByRole("button", { name: "Close" }).click(); await expect(notebookActions).toBeFocused(); + const rssSummary = page.getByRole("button", { name: "Open widget details: RSS feeds" }); + await rssSummary.click(); + const rssDetails = page.getByRole("dialog", { name: "RSS feeds" }); + await expect(rssDetails).toBeVisible(); + await rssDetails.getByRole("button", { name: "Close" }).click(); + await expect(rssSummary).toBeFocused(); + const rtlOverflow = await page.evaluate(() => { document.documentElement.dir = "rtl"; return document.documentElement.scrollWidth - document.documentElement.clientWidth; @@ -233,7 +293,56 @@ describe("Automatic mobile board", () => { await desktopPage.goto(`${baseUrl}/boards/${boardName}`); await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0); await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36); + + await desktopPage.getByRole("button", { name: "Edit item", exact: true }).click(); + await desktopPage.setViewportSize({ width: 390, height: 844 }); + await expect(desktopPage.locator("[data-mobile-board]")).toBeVisible(); + await desktopPage.getByRole("button", { name: "More" }).click(); + await desktopPage.getByRole("dialog", { name: "More" }).getByRole("link", { name: "Settings" }).click(); + const unsavedChangesDialog = desktopPage.getByRole("dialog", { name: "Unsaved changes" }); + await expect(unsavedChangesDialog).toBeVisible(); + await desktopPage.keyboard.press("Escape"); + await expect(unsavedChangesDialog).toBeHidden(); + await desktopPage.goBack(); + await expect(unsavedChangesDialog).toBeVisible(); + await desktopPage.goBack(); + await expect(desktopPage.getByRole("dialog", { name: "Unsaved changes" })).toHaveCount(1); + await desktopPage.keyboard.press("Escape"); + await expect(unsavedChangesDialog).toBeHidden(); + await desktopPage.setViewportSize({ width: 800, height: 900 }); + await desktopPage.getByRole("button", { name: "Edit item", exact: true }).click(); + await expect(desktopPage.getByText("The board was successfully saved")).toBeVisible(); + await expect + .poll(() => desktopPage.evaluate(() => window.history.state?.["__homarrBoardEditGuard"] ?? null)) + .toBeNull(); + + await setAutomaticMobileLayoutAsync(desktopPage, false); await desktopContext.close(); + + const legacyMobileContext = await browser.newContext({ + storageState, + viewport: { width: 390, height: 844 }, + userAgent: devices["iPhone 13"].userAgent, + hasTouch: true, + isMobile: true, + }); + const legacyMobilePage = await legacyMobileContext.newPage(); + await legacyMobilePage.goto(`${baseUrl}/boards/${boardName}`); + await expect(legacyMobilePage.locator("[data-mobile-board]")).toHaveCount(0); + await expect(legacyMobilePage.locator(".grid-stack-item")).toHaveCount(36); + await expect(legacyMobilePage.getByRole("button", { name: "Edit item" })).toBeVisible(); + await expect(legacyMobilePage.getByRole("button", { name: "More" })).toHaveCount(0); + await legacyMobilePage.getByRole("button", { name: "First section" }).click(); + await legacyMobilePage.getByRole("button", { name: "Second section" }).click(); + await expect + .poll(async () => + legacyMobilePage + .locator(".grid-stack") + .first() + .evaluate((element) => getComputedStyle(element).getPropertyValue("--gridstack-column-count").trim()), + ) + .toBe("4"); + await legacyMobileContext.close(); } finally { await loginContext.close(); await browser.close(); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 1993b8231..ec74a2baf 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -49,6 +49,7 @@ import { boardDuplicateSchema, boardRenameSchema, boardSaveLayoutSchema, + boardSaveLayoutsSchema, boardSavePartialSettingsSchema, boardSavePermissionsSchema, boardSaveSchema, @@ -687,7 +688,7 @@ export const boardRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)", + "Set a board's desktop column count while preserving saved responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)", }, }) .input(boardSaveLayoutSchema) @@ -761,12 +762,8 @@ export const boardRouter = createTRPCRouter({ await transaction .update(schema.layouts) - .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) + .set({ columnCount: input.columnCount }) .where(eq(schema.layouts.id, desktopLayout.id)); - - await transaction - .delete(schema.layouts) - .where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id)))); }); }, handleSync(db) { @@ -811,18 +808,230 @@ export const boardRouter = createTRPCRouter({ transaction .update(layouts) - .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) + .set({ columnCount: input.columnCount }) .where(eq(layouts.id, desktopLayout.id)) .run(); - - transaction - .delete(layouts) - .where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id)))) - .run(); }); }, }); }), + saveLayouts: protectedProcedure + .input(boardSaveLayoutsSchema) + .output(boardSaveLayoutsSchema.shape.layouts) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); + + const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); + const addedLayouts = filterAddedItems(input.layouts, board.layouts); + const layoutsToInsert: InferInsertModel[] = []; + const itemSectionLayoutsToInsert: InferInsertModel[] = []; + const sectionLayoutsToInsert: InferInsertModel[] = []; + const savedLayoutIds = new Map(); + + for (const addedLayout of addedLayouts) { + const layoutId = createId(); + savedLayoutIds.set(addedLayout.id, layoutId); + + layoutsToInsert.push({ + id: layoutId, + name: addedLayout.name, + columnCount: addedLayout.columnCount, + breakpoint: addedLayout.breakpoint, + boardId: board.id, + }); + + const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount); + const layoutToClone = + sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1); + + if (!layoutToClone) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" }); + } + + const updatedBoardLayout = getUpdatedBoardLayout(board, { + previous: { + layoutId: layoutToClone.id, + columnCount: layoutToClone.columnCount, + }, + current: { + layoutId, + columnCount: addedLayout.columnCount, + }, + }); + + itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts); + sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts); + } + + const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts); + const itemSectionLayoutsToUpdate: InferInsertModel[] = []; + const sectionLayoutsToUpdate: InferInsertModel[] = []; + const layoutsToUpdate: typeof input.layouts = []; + + for (const updatedLayout of updatedLayouts) { + const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id); + if (!dbLayout) continue; + + if (dbLayout.columnCount !== updatedLayout.columnCount) { + const updatedBoardLayout = getUpdatedBoardLayout(board, { + previous: { + layoutId: dbLayout.id, + columnCount: dbLayout.columnCount, + }, + current: { + layoutId: dbLayout.id, + columnCount: updatedLayout.columnCount, + }, + }); + + itemSectionLayoutsToUpdate.push(...updatedBoardLayout.itemSectionLayouts); + sectionLayoutsToUpdate.push(...updatedBoardLayout.sectionLayouts); + } + + layoutsToUpdate.push(updatedLayout); + } + + const removedLayoutIds = filterRemovedItems(input.layouts, board.layouts).map((layout) => layout.id); + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + if (layoutsToInsert.length > 0) { + await transaction.insert(schema.layouts).values(layoutsToInsert); + } + if (itemSectionLayoutsToInsert.length > 0) { + await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert); + } + if (sectionLayoutsToInsert.length > 0) { + await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert); + } + + for (const itemSectionLayout of itemSectionLayoutsToUpdate) { + await transaction + .update(schema.itemLayouts) + .set({ + height: itemSectionLayout.height, + width: itemSectionLayout.width, + xOffset: itemSectionLayout.xOffset, + yOffset: itemSectionLayout.yOffset, + sectionId: itemSectionLayout.sectionId, + }) + .where( + and( + eq(schema.itemLayouts.itemId, itemSectionLayout.itemId), + eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId), + ), + ); + } + + for (const sectionLayout of sectionLayoutsToUpdate) { + await transaction + .update(schema.sectionLayouts) + .set({ + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + parentSectionId: sectionLayout.parentSectionId, + }) + .where( + and( + eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId), + eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId), + ), + ); + } + + for (const layout of layoutsToUpdate) { + await transaction + .update(schema.layouts) + .set({ + name: layout.name, + columnCount: layout.columnCount, + breakpoint: layout.breakpoint, + }) + .where(eq(schema.layouts.id, layout.id)); + } + + if (removedLayoutIds.length > 0) { + await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds)); + } + }); + }, + handleSync(db) { + db.transaction((transaction) => { + if (layoutsToInsert.length > 0) { + transaction.insert(layouts).values(layoutsToInsert).run(); + } + if (itemSectionLayoutsToInsert.length > 0) { + transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run(); + } + if (sectionLayoutsToInsert.length > 0) { + transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run(); + } + + for (const itemSectionLayout of itemSectionLayoutsToUpdate) { + transaction + .update(itemLayouts) + .set({ + height: itemSectionLayout.height, + width: itemSectionLayout.width, + xOffset: itemSectionLayout.xOffset, + yOffset: itemSectionLayout.yOffset, + sectionId: itemSectionLayout.sectionId, + }) + .where( + and( + eq(itemLayouts.itemId, itemSectionLayout.itemId), + eq(itemLayouts.layoutId, itemSectionLayout.layoutId), + ), + ) + .run(); + } + + for (const sectionLayout of sectionLayoutsToUpdate) { + transaction + .update(sectionLayouts) + .set({ + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + parentSectionId: sectionLayout.parentSectionId, + }) + .where( + and( + eq(sectionLayouts.sectionId, sectionLayout.sectionId), + eq(sectionLayouts.layoutId, sectionLayout.layoutId), + ), + ) + .run(); + } + + for (const layout of layoutsToUpdate) { + transaction + .update(layouts) + .set({ + name: layout.name, + columnCount: layout.columnCount, + breakpoint: layout.breakpoint, + }) + .where(eq(layouts.id, layout.id)) + .run(); + } + + if (removedLayoutIds.length > 0) { + transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run(); + } + }); + }, + }); + + return input.layouts.map((layout) => ({ + ...layout, + id: savedLayoutIds.get(layout.id) ?? layout.id, + })); + }), savePartialBoardSettings: protectedProcedure .meta({ openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true }, @@ -1491,16 +1700,16 @@ export const boardRouter = createTRPCRouter({ } const itemId = createId(); - - await ctx.db.insert(items).values({ + const itemRow = { id: itemId, boardId: input.boardId, kind: input.kind, options: superjson.stringify(input.options), advancedOptions: emptySuperJSON, - }); + }; const layoutRows: (typeof itemLayouts.$inferInsert)[] = []; + const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 }; for (const layout of board.layouts) { const existingInSection = board.items @@ -1519,14 +1728,17 @@ export const boardRouter = createTRPCRouter({ } } - const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 }; + const layoutItemSize = { + ...defaultSize, + width: Math.min(defaultSize.width, layout.columnCount), + }; const fitsAt = (x: number, y: number) => { - if (x + defaultSize.width > layout.columnCount) return false; - for (let dy = 0; dy < defaultSize.height; dy++) { + if (x + layoutItemSize.width > layout.columnCount) return false; + for (let dy = 0; dy < layoutItemSize.height; dy++) { const row = occupied[y + dy]; if (!row) continue; - for (let dx = 0; dx < defaultSize.width; dx++) { + for (let dx = 0; dx < layoutItemSize.width; dx++) { if (row[x + dx]) return false; } } @@ -1544,8 +1756,8 @@ export const boardRouter = createTRPCRouter({ layoutId: layout.id, xOffset: x, yOffset: y, - width: defaultSize.width, - height: defaultSize.height, + width: layoutItemSize.width, + height: layoutItemSize.height, }); placed = true; } @@ -1560,15 +1772,32 @@ export const boardRouter = createTRPCRouter({ } } - if (layoutRows.length > 0) { - await ctx.db.insert(itemLayouts).values(layoutRows); - } + const integrationRows = input.integrationIds.map((integrationId) => ({ itemId, integrationId })); - if (input.integrationIds.length > 0) { - await ctx.db - .insert(integrationItems) - .values(input.integrationIds.map((integrationId) => ({ itemId, integrationId }))); - } + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + await transaction.insert(schema.items).values(itemRow); + if (layoutRows.length > 0) { + await transaction.insert(schema.itemLayouts).values(layoutRows); + } + if (integrationRows.length > 0) { + await transaction.insert(schema.integrationItems).values(integrationRows); + } + }); + }, + handleSync(db) { + db.transaction((transaction) => { + transaction.insert(items).values(itemRow).run(); + if (layoutRows.length > 0) { + transaction.insert(itemLayouts).values(layoutRows).run(); + } + if (integrationRows.length > 0) { + transaction.insert(integrationItems).values(integrationRows).run(); + } + }); + }, + }); return { itemId }; }), diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 9783e0ae9..dc117fc0c 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -19,6 +19,7 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; const boardServerSettingsSchema = z.object({ homeBoardId: z.string().nullable(), mobileHomeBoardId: z.string().nullable(), + enableAutomaticMobileLayout: z.boolean(), enableStatusByDefault: z.boolean(), forceDisableStatus: z.boolean(), }) satisfies z.ZodType; @@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission", + "Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission", }, }) .input(z.void()) @@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null", + "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null", }, }) .input(boardServerSettingsUpdateSchema) @@ -103,10 +104,19 @@ export const serverSettingsRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey); - await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, { + const next = { ...current, ...input.value, - } as ServerSettings[keyof ServerSettings]); + } as ServerSettings[keyof ServerSettings]; + const existing = await ctx.db.query.serverSettings.findFirst({ + where: eq(serverSettings.settingKey, input.settingsKey), + }); + + if (existing) { + await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next); + } else { + await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next); + } }), initSettings: onboardingProcedure .requiresStep("settings") diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 94a46bc31..8239f67c1 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import type { Database, InferInsertModel } from "@homarr/db"; -import { and, eq } from "@homarr/db"; +import { and, eq, not } from "@homarr/db"; import { boardGroupPermissions, boards, @@ -1382,6 +1382,187 @@ describe("saveGroupBoardPermissions should save group board permissions", () => ); }); +const createExistingLayout = (id: string) => ({ + id, + name: "Base", + columnCount: 10, + breakpoint: 0, +}); + +const createNewLayout = (columnCount: number) => ({ + id: createId(), + name: "New layout", + columnCount, + breakpoint: 1400, +}); + +describe("saveLayouts should preserve legacy responsive layout editing", () => { + test("should add a layout and clone item and dynamic-section placements", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default"); + const assignments = await createItemsAndSectionsAsync(db, { + boardId, + layoutId, + sectionId, + }); + const newLayout = createNewLayout(3); + + const savedLayouts = await caller.saveLayouts({ + id: boardId, + layouts: [createExistingLayout(layoutId), newLayout], + }); + + const layout = expectToBeDefined( + await db.query.layouts.findFirst({ + where: not(eq(layouts.id, layoutId)), + }), + ); + expect(layout).toMatchObject({ + name: newLayout.name, + columnCount: newLayout.columnCount, + breakpoint: newLayout.breakpoint, + }); + expect(savedLayouts).toEqual([createExistingLayout(layoutId), { ...newLayout, id: layout.id }]); + await expectLayoutForRootLayoutAsync(db, sectionId, layout.id, { + ...assignments.inRoot, + a: itemId, + }); + await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout.id, assignments.inDynamicSection); + + await caller.saveLayouts({ id: boardId, layouts: savedLayouts }); + await expect(db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) })).resolves.toHaveLength(2); + }); + + test("should update layout metadata and positions", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default"); + const assignments = await createItemsAndSectionsAsync(db, { + boardId, + layoutId, + sectionId, + }); + + await caller.saveLayouts({ + id: boardId, + layouts: [ + { + id: layoutId, + name: "Phone", + columnCount: 3, + breakpoint: 480, + }, + ], + }); + + await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toMatchObject({ + name: "Phone", + columnCount: 3, + breakpoint: 480, + }); + await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, { + ...assignments.inRoot, + a: itemId, + }); + await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection); + }); + + test("should remove layouts omitted from the saved responsive configuration", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId } = await createFullBoardAsync(db, "default"); + + await caller.saveLayouts({ + id: boardId, + layouts: [createNewLayout(12)], + }); + + await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toBeUndefined(); + }); + + test("should reject duplicate responsive breakpoints", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId } = await createFullBoardAsync(db, "default"); + + await expect( + caller.saveLayouts({ + id: boardId, + layouts: [ + createExistingLayout(layoutId), + { + ...createNewLayout(4), + breakpoint: 0, + }, + ], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + test("should reject duplicate responsive layout IDs", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId } = await createFullBoardAsync(db, "default"); + const existingLayout = createExistingLayout(layoutId); + + await expect( + caller.saveLayouts({ + id: boardId, + layouts: [existingLayout, { ...existingLayout, name: "Phone", breakpoint: 480 }], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + test("should reject fractional responsive layout values", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId } = await createFullBoardAsync(db, "default"); + + await expect( + caller.saveLayouts({ + id: boardId, + layouts: [{ ...createExistingLayout(layoutId), breakpoint: 480.5 }], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + await expect( + caller.saveLayouts({ + id: boardId, + layouts: [{ ...createExistingLayout(layoutId), columnCount: 3.5 }], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); +}); + +describe("addItem should preserve every saved layout", () => { + test("should clamp wide widgets to a retained narrow layout", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId } = await createFullBoardAsync(db, "default"); + const mobileLayoutId = createId(); + await db.insert(layouts).values({ + id: mobileLayoutId, + name: "Mobile", + columnCount: 1, + breakpoint: 480, + boardId, + }); + + const { itemId } = await caller.addItem({ + boardId, + kind: "mediaMissing", + }); + + const savedItemLayouts = await db.query.itemLayouts.findMany({ + where: eq(itemLayouts.itemId, itemId), + }); + expect(savedItemLayouts).toHaveLength(2); + expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId, width: 4 })); + expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId: mobileLayoutId, width: 1 })); + }); +}); + describe("saveLayout should save the desktop layout", () => { test("should update position of items when column count changes", async () => { // Arrange @@ -1407,7 +1588,7 @@ describe("saveLayout should save the desktop layout", () => { }); await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection); }); - test("should keep the desktop layout and remove legacy responsive layouts", async () => { + test("should preserve legacy layouts and desktop metadata", async () => { // Arrange const db = createDb(); const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); @@ -1434,15 +1615,18 @@ describe("saveLayout should save the desktop layout", () => { // Act await caller.saveLayout({ id: boardId, - columnCount: 16, + columnCount: 14, }); // Assert const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); - expect(savedLayouts).toEqual([ - expect.objectContaining({ id: desktopLayoutId, name: "Base", columnCount: 16, breakpoint: 0 }), - ]); - expect(savedLayouts.some((layout) => layout.id === layoutId)).toBe(false); + expect(savedLayouts).toHaveLength(2); + expect(savedLayouts).toContainEqual( + expect.objectContaining({ id: layoutId, name: "Base", columnCount: 10, breakpoint: 0 }), + ); + expect(savedLayouts).toContainEqual( + expect.objectContaining({ id: desktopLayoutId, name: "Large", columnCount: 14, breakpoint: 1200 }), + ); }); test("should fail when board not found", async () => { // Arrange diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index c045b1288..d3d43b213 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -56,6 +56,33 @@ describe("getAll server settings", () => { }); describe("saveSettings", () => { + test("saveSettings should persist settings when defaults are not seeded", async () => { + const db = createDb(); + const caller = serverSettingsRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSession, + }); + + await caller.saveSettings({ + settingsKey: "board", + value: { + enableAutomaticMobileLayout: false, + }, + }); + + const dbSettings = await db.select().from(serverSettings); + expect(dbSettings).toStrictEqual([ + { + settingKey: "board", + value: stringify({ + ...defaultServerSettings.board, + enableAutomaticMobileLayout: false, + }), + }, + ]); + }); + test("saveSettings should update settings and return true when it updated only one", async () => { const db = createDb(); const caller = serverSettingsRouter.createCaller({ @@ -103,6 +130,27 @@ describe("board settings API", () => { await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board); }); + test("getBoardSettings should backfill the automatic layout default for legacy values", async () => { + const db = createDb(); + const caller = serverSettingsRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSession, + }); + const legacyBoardSettings = { + homeBoardId: defaultServerSettings.board.homeBoardId, + mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId, + enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault, + forceDisableStatus: defaultServerSettings.board.forceDisableStatus, + }; + await db.insert(serverSettings).values({ + settingKey: "board", + value: stringify(legacyBoardSettings), + }); + + await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board); + }); + test("updateBoardSettings should insert settings when defaults are not persisted", async () => { const db = createDb(); const caller = serverSettingsRouter.createCaller({ @@ -145,6 +193,7 @@ describe("board settings API", () => { await caller.updateBoardSettings({ homeBoardId: boardId }); const result = await caller.updateBoardSettings({ mobileHomeBoardId: boardId, + enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); @@ -152,6 +201,7 @@ describe("board settings API", () => { ...defaultServerSettings.board, homeBoardId: boardId, mobileHomeBoardId: boardId, + enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); const dbSettings = await db.select().from(serverSettings); diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index 179610e31..b7c80ac39 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -2,36 +2,53 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -export const mediaReleaseRouter = createTRPCRouter({ - getMediaReleases: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) - .query(async ({ ctx }) => { - const results = await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); +const mediaReleasesProcedure = publicProcedure.concat( + createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")), +); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - releases: data, - }; +type MediaReleaseIntegration = Parameters[0]; + +const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => { + const results = await settleIntegrationQueries( + integrations, + async (integration) => { + const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); + const { data, timestamp, isStale } = await innerHandler.getDataWithProvenanceAsync(); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, }, - { throwOnAllFailure: true }, - ); - return results.flatMap((result) => - result.releases.map((release) => ({ - ...release, - integration: result.integration, - })), - ); - }), + releases: data, + isStale, + }; + }, + { throwOnAllFailure: true }, + ); + const items = results.flatMap((result) => + result.releases.map((release) => ({ + ...release, + integration: result.integration, + })), + ); + + return { + items, + ...getIntegrationQueryProvenance(integrations.length, results), + }; +}; + +export const mediaReleaseRouter = createTRPCRouter({ + getMediaReleases: mediaReleasesProcedure.query(async ({ ctx }) => { + return (await getMediaReleasesWithProvenanceAsync(ctx.integrations)).items; + }), + getMediaReleasesWithProvenance: mediaReleasesProcedure.query(async ({ ctx }) => { + return await getMediaReleasesWithProvenanceAsync(ctx.integrations); + }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 700b1d007..88d9b7698 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; +import type { MediaRequestListInput } from "@homarr/request-handler/media-request-list"; import { mediaRequestListInputSchema, mediaRequestListRequestHandler, @@ -10,11 +11,93 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; +const latestRequestsProcedure = publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .input(mediaRequestListInputSchema); +const requestStatsProcedure = publicProcedure.concat( + createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), +); + +type MediaRequestIntegration = Parameters[0]; +type MediaRequestStatsIntegration = Parameters[0]; + +const getLatestRequestsWithProvenanceAsync = async ( + integrations: MediaRequestIntegration[], + input: MediaRequestListInput, +) => { + const results = await settleIntegrationQueries( + integrations, + async (integration) => { + const { data, isStale } = await mediaRequestListRequestHandler + .handler(integration, input) + .getDataWithProvenanceAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data, + isStale, + }; + }, + { throwOnAllFailure: true }, + ); + const items = results + .flatMap(({ data, integration }) => + data.map((request) => ({ + ...request, + integrationId: integration.id, + })), + ) + .toSorted((dataA, dataB) => { + if (dataA.status === dataB.status) { + return dataB.createdAt.getTime() - dataA.createdAt.getTime(); + } + + return ( + mediaRequestStatusConfiguration[dataA.status].position - mediaRequestStatusConfiguration[dataB.status].position + ); + }); + + return { + items, + ...getIntegrationQueryProvenance(integrations.length, results), + }; +}; + +const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => { + const results = await settleIntegrationQueries( + integrations, + async (integration) => { + const { data, isStale } = await mediaRequestStatsRequestHandler + .handler(integration, {}) + .getDataWithProvenanceAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data, + isStale, + }; + }, + { throwOnAllFailure: true }, + ); + + return { + stats: results.flatMap((result) => result.data.stats), + users: results + .flatMap((result) => + result.data.users.map((user) => ({ + ...user, + integration: result.integration, + })), + ) + .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), + integrations: results.map((result) => result.integration), + ...getIntegrationQueryProvenance(integrations.length, results), + }; +}; + export const mediaRequestsRouter = createTRPCRouter({ - getLatestRequests: publicProcedure + getLatestRequests: latestRequestsProcedure .meta({ mcp: { enabled: true, @@ -22,36 +105,13 @@ export const mediaRequestsRouter = createTRPCRouter({ "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", }, }) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) - .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { - const results = await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }, - { throwOnAllFailure: true }, - ); - return results - .flatMap(({ data, integration }) => - data.map((request) => ({ - ...request, - integrationId: integration.id, - })), - ) - .toSorted((dataA, dataB) => { - if (dataA.status === dataB.status) { - return dataB.createdAt.getTime() - dataA.createdAt.getTime(); - } - - return ( - mediaRequestStatusConfiguration[dataA.status].position - - mediaRequestStatusConfiguration[dataB.status].position - ); - }); + return (await getLatestRequestsWithProvenanceAsync(ctx.integrations, input)).items; }), - getStats: publicProcedure + getLatestRequestsWithProvenance: latestRequestsProcedure.query(async ({ ctx, input }) => { + return await getLatestRequestsWithProvenanceAsync(ctx.integrations, input); + }), + getStats: requestStatsProcedure .meta({ mcp: { enabled: true, @@ -59,30 +119,17 @@ export const mediaRequestsRouter = createTRPCRouter({ "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", }, }) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }, - { throwOnAllFailure: true }, - ); + const result = await getRequestStatsWithProvenanceAsync(ctx.integrations); return { - stats: results.flatMap((result) => result.data.stats), - users: results - .map((result) => - result.data.users.map((user) => ({ - ...user, - integration: result.integration, - })), - ) - .flat() - .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), - integrations: results.map((result) => result.integration), + stats: result.stats, + users: result.users, + integrations: result.integrations, }; }), + getStatsWithProvenance: requestStatsProcedure.query(async ({ ctx }) => { + return await getRequestStatsWithProvenanceAsync(ctx.integrations); + }), answerRequest: protectedProcedure .meta({ mcp: { diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts index ca032b700..efddcf136 100644 --- a/packages/api/src/settle-integrations.spec.ts +++ b/packages/api/src/settle-integrations.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; -import { settleIntegrationQueries } from "./settle-integrations"; +import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations"; const integrations = [ { id: "first", name: "First", kind: "mock" }, @@ -76,3 +76,19 @@ describe("settleIntegrationQueries", () => { ).resolves.toEqual([]); }); }); + +describe("getIntegrationQueryProvenance", () => { + it("counts dropped and stale integration results independently", () => { + expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({ + failedIntegrationCount: 1, + staleIntegrationCount: 2, + }); + }); + + it("does not report failures for complete current results", () => { + expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({ + failedIntegrationCount: 0, + staleIntegrationCount: 0, + }); + }); +}); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index 09278fb97..78734ec58 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -14,6 +14,19 @@ interface Options { throwOnAllFailure?: boolean; } +export interface IntegrationQueryProvenance { + failedIntegrationCount: number; + staleIntegrationCount: number; +} + +export const getIntegrationQueryProvenance = ( + selectedIntegrationCount: number, + results: readonly { isStale?: boolean }[], +): IntegrationQueryProvenance => ({ + failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length), + staleIntegrationCount: results.filter((result) => result.isStale === true).length, +}); + export async function settleIntegrationQueries( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index e39e0e4ab..94da929ac 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -33,6 +33,7 @@ test("MCP tools should contain expected procedures", () => { expect(toolNames).toContain("app_byId"); expect(toolNames).toContain("app_create"); expect(toolNames).toContain("board_savePartialBoardSettings"); + expect(toolNames).toContain("board_saveLayout"); expect(toolNames).toContain("board_duplicateBoard"); expect(toolNames).toContain("info_getInfo"); expect(toolNames).toContain("invite_getAll"); diff --git a/packages/boards/package.json b/packages/boards/package.json index b9fd9a40d..353803c0e 100644 --- a/packages/boards/package.json +++ b/packages/boards/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@homarr/api": "workspace:^0.1.0", + "@homarr/settings": "workspace:^0.1.0", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/boards/src/context.spec.ts b/packages/boards/src/context.spec.ts new file mode 100644 index 000000000..a4ebfcb25 --- /dev/null +++ b/packages/boards/src/context.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; + +import type { RouterOutputs } from "@homarr/api"; + +import { getBoardLayouts, getCurrentLayout, getDesktopLayout } from "./context"; + +const createBoard = () => + ({ + layouts: [ + { id: "small", name: "Small", columnCount: 4, breakpoint: 0 }, + { id: "medium", name: "Medium", columnCount: 8, breakpoint: 768 }, + { id: "large", name: "Large", columnCount: 12, breakpoint: 1200 }, + ], + }) as RouterOutputs["board"]["getBoardByName"]; + +describe("board layout selection", () => { + test("automatic mode always uses the desktop layout", () => { + const board = createBoard(); + + expect(getDesktopLayout(board).id).toBe("large"); + expect(getCurrentLayout(board, true, 390)).toBe("large"); + }); + + test.each([ + [390, "small"], + [768, "medium"], + [1024, "medium"], + [1440, "large"], + ])("legacy mode selects the matching responsive layout at %spx", (viewportWidth, expectedLayoutId) => { + expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId); + }); + + test("keeps every saved layout available for reversible mutations", () => { + expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]); + }); +}); diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx index b76ea03a6..fea125673 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -1,12 +1,13 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext, useEffect } from "react"; +import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache"; +import { useSettings } from "@homarr/settings"; import { updateBoardName } from "./updater"; @@ -73,10 +74,6 @@ export const useOptionalBoard = () => { return context?.board ?? null; }; -export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => { - return getDesktopLayout(board).id; -}; - export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"]) => { const layout = board.layouts .toSorted( @@ -91,9 +88,57 @@ export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"] return layout; }; -export const useCurrentLayout = () => getCurrentLayout(useRequiredBoard()); +export const getCurrentLayout = ( + board: RouterOutputs["board"]["getBoardByName"], + enableAutomaticMobileLayout: boolean, + viewportWidth?: number, +) => { + if (enableAutomaticMobileLayout || viewportWidth === undefined) { + return getDesktopLayout(board).id; + } -export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) => [getCurrentLayout(board)]; + const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint); + const layout = sortedLayouts.find((candidate) => candidate.breakpoint <= viewportWidth) ?? sortedLayouts.at(-1); + + if (!layout) { + throw new Error("Board must have a layout"); + } + + return layout.id; +}; + +export const useCurrentLayout = () => { + const board = useRequiredBoard(); + const { enableAutomaticMobileLayout } = useSettings(); + const [currentLayout, setCurrentLayout] = useState(() => + getCurrentLayout( + board, + enableAutomaticMobileLayout, + !enableAutomaticMobileLayout && typeof window !== "undefined" ? window.innerWidth : undefined, + ), + ); + + const updateCurrentLayout = useCallback(() => { + setCurrentLayout(getCurrentLayout(board, enableAutomaticMobileLayout, window.innerWidth)); + }, [board, enableAutomaticMobileLayout]); + + useEffect(() => { + updateCurrentLayout(); + + if (enableAutomaticMobileLayout) return; + + window.addEventListener("resize", updateCurrentLayout); + + return () => { + window.removeEventListener("resize", updateCurrentLayout); + }; + }, [enableAutomaticMobileLayout, updateCurrentLayout]); + + return currentLayout; +}; + +export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) => + board.layouts.map((layout) => layout.id); export const useLayouts = () => { const board = useRequiredBoard(); diff --git a/packages/boards/src/edit-mode.spec.ts b/packages/boards/src/edit-mode.spec.ts new file mode 100644 index 000000000..25bf237f8 --- /dev/null +++ b/packages/boards/src/edit-mode.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { BoardEditAction, BoardEditActionEvent } from "./edit-mode"; +import { boardEditActionEventName, requestBoardEditAction } from "./edit-mode"; + +describe("requestBoardEditAction", () => { + test("runs the action immediately when no edit guard is mounted", () => { + const action = vi.fn(); + + requestBoardEditAction(action); + + expect(action).toHaveBeenCalledOnce(); + }); + + test("lets an edit guard defer the action until the user confirms", () => { + const action = vi.fn(); + let deferredAction: BoardEditAction | undefined; + const listener = (event: Event) => { + event.preventDefault(); + deferredAction = (event as BoardEditActionEvent).detail.action; + }; + document.addEventListener(boardEditActionEventName, listener, { once: true }); + + requestBoardEditAction(action); + expect(action).not.toHaveBeenCalled(); + + void deferredAction?.(); + expect(action).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 6ca606b99..ce5395b30 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -6,6 +6,28 @@ import { useDisclosure } from "@mantine/hooks"; const EditModeContext = createContext | null>(null); +export const boardEditActionEventName = "homarr:board-edit-action"; + +export type BoardEditActionResult = boolean | void; +export type BoardEditAction = () => BoardEditActionResult | Promise; +export type BoardEditActionEvent = CustomEvent<{ action: BoardEditAction }>; + +export const requestBoardEditAction = (action: BoardEditAction) => { + if (typeof document === "undefined") { + void action(); + return; + } + + const event: BoardEditActionEvent = new CustomEvent(boardEditActionEventName, { + cancelable: true, + detail: { action }, + }); + + if (document.dispatchEvent(event)) { + void action(); + } +}; + const noop = () => undefined; const readOnlyEditMode = [false, { open: noop, close: noop, toggle: noop, set: noop }] satisfies ReturnType< typeof useDisclosure diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts new file mode 100644 index 000000000..b9139ace1 --- /dev/null +++ b/packages/db/migrations/seed.spec.ts @@ -0,0 +1,106 @@ +import { stringify } from "superjson"; +import { describe, expect, test } from "vitest"; + +import { createId } from "@homarr/common"; +import { eq } from "@homarr/db"; +import { + getServerSettingByKeyAsync, + getServerSettingsAsync, + shouldEnableAutomaticMobileLayoutForUpgrade, +} from "@homarr/db/queries"; +import { boards, layouts, serverSettings } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import { defaultServerSettings } from "@homarr/server-settings"; + +import { seedServerSettingsAsync } from "./seed"; + +describe("automatic mobile layout upgrade default", () => { + test("enables automatic mode for a new instance", () => { + expect(shouldEnableAutomaticMobileLayoutForUpgrade([])).toBe(true); + }); + + test("enables automatic mode when every existing board has only its canonical layout", () => { + expect( + shouldEnableAutomaticMobileLayoutForUpgrade([ + { boardId: "first", name: "Base", breakpoint: 0 }, + { boardId: "second", name: "Base", breakpoint: 0 }, + ]), + ).toBe(true); + }); + + test("preserves legacy mode when a board has multiple responsive layouts", () => { + expect( + shouldEnableAutomaticMobileLayoutForUpgrade([ + { boardId: "board", name: "Base", breakpoint: 0 }, + { boardId: "board", name: "Mobile", breakpoint: 480 }, + ]), + ).toBe(false); + }); + + test.each([ + { boardId: "board", name: "Phone", breakpoint: 0 }, + { boardId: "board", name: "Base", breakpoint: 480 }, + ])("preserves legacy mode for a noncanonical saved layout", (existingLayout) => { + expect(shouldEnableAutomaticMobileLayoutForUpgrade([existingLayout])).toBe(false); + }); + + test("uses the upgrade heuristic when migrations and seeding are skipped", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false }); + await db.insert(layouts).values([ + { id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 }, + { id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 }, + ]); + + await expect(getServerSettingByKeyAsync(db, "board")).resolves.toMatchObject({ + enableAutomaticMobileLayout: false, + }); + await expect(getServerSettingsAsync(db)).resolves.toMatchObject({ + board: { + enableAutomaticMobileLayout: false, + }, + }); + }); + + test("backfills legacy responsive instances once without overriding an explicit choice", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false }); + await db.insert(layouts).values([ + { id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 }, + { id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 }, + ]); + await db.insert(serverSettings).values({ + settingKey: "board", + value: stringify({ + homeBoardId: null, + mobileHomeBoardId: null, + enableStatusByDefault: true, + forceDisableStatus: false, + }), + }); + + await seedServerSettingsAsync(db); + await seedServerSettingsAsync(db); + + await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({ + ...defaultServerSettings.board, + enableAutomaticMobileLayout: false, + }); + + await db + .update(serverSettings) + .set({ + value: stringify({ + ...defaultServerSettings.board, + enableAutomaticMobileLayout: true, + }), + }) + .where(eq(serverSettings.settingKey, "board")); + + await seedServerSettingsAsync(db); + + await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board); + }); +}); diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 6ec906cec..a7d11d52b 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -21,6 +21,7 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, insertServerSettingByKeyAsync, + shouldEnableAutomaticMobileLayoutForUpgrade, updateServerSettingByKeyAsync, } from "../queries/server-setting"; @@ -245,19 +246,33 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => { console.log("Set Homarr docs as the default search engine"); }; -const seedServerSettingsAsync = async (db: Database) => { +export const seedServerSettingsAsync = async (db: Database) => { const serverSettingsData = await db.query.serverSettings.findMany(); + const existingLayouts = await db.query.layouts.findMany({ + columns: { + boardId: true, + name: true, + breakpoint: true, + }, + }); + const defaultsForThisInstance = { + ...defaultServerSettings, + board: { + ...defaultServerSettings.board, + enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts), + }, + }; for (const settingsKey of defaultServerSettingsKeys) { const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey); if (!currentDbEntry) { - await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]); + await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]); console.log(`Created serverSetting through seed key=${settingsKey}`); continue; } - const currentSettings = await getServerSettingByKeyAsync(db, settingsKey); - const defaultSettings = defaultServerSettings[settingsKey]; + const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value); + const defaultSettings = defaultsForThisInstance[settingsKey]; const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings)); if (missingKeys.length === 0) { diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts index 371b18f29..679db8752 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -1,4 +1,4 @@ -import SuperJSON from "superjson"; +import { parse, stringify } from "superjson"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; @@ -7,23 +7,71 @@ import type { Database } from ".."; import { eq } from ".."; import { serverSettings } from "../schema"; +interface ExistingBoardLayout { + boardId: string; + name: string; + breakpoint: number; +} + +export const shouldEnableAutomaticMobileLayoutForUpgrade = (existingLayouts: ExistingBoardLayout[]) => { + const layoutCounts = new Map(); + + for (const layout of existingLayouts) { + if (layout.name !== "Base" || layout.breakpoint !== 0) { + return false; + } + + const nextCount = (layoutCounts.get(layout.boardId) ?? 0) + 1; + if (nextCount > 1) { + return false; + } + layoutCounts.set(layout.boardId, nextCount); + } + + return true; +}; + +const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => { + const existingLayouts = await db.query.layouts.findMany({ + columns: { + boardId: true, + name: true, + breakpoint: true, + }, + }); + + return shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts); +}; + export const getServerSettingsAsync = async (db: Database) => { const settings = await db.query.serverSettings.findMany(); - return defaultServerSettingsKeys.reduce((acc, settingKey) => { - const setting = settings.find((setting) => setting.settingKey === settingKey); + const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => { + const setting = settings.find((candidate) => candidate.settingKey === settingKey); if (!setting) { // Typescript is not happy because the key is a union and it does not know that they are the same - acc[settingKey] = defaultServerSettings[settingKey] as never; + acc[settingKey] = { ...defaultServerSettings[settingKey] } as never; return acc; } + const persistedSettings = parse>(setting.value); acc[settingKey] = { ...defaultServerSettings[settingKey], - ...SuperJSON.parse(setting.value), - }; + ...persistedSettings, + } as never; return acc; }, {} as ServerSettings); + + const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board"); + const parsedBoardSettings = persistedBoardSettings + ? parse>(persistedBoardSettings.value) + : {}; + + if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") { + mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db); + } + + return mergedSettings; }; export const getServerSettingByKeyAsync = async (db: Database, key: TKey) => { @@ -32,10 +80,31 @@ export const getServerSettingByKeyAsync = async (dbSettings.value); + const persistedSettings = parse>(dbSettings.value); + const mergedSettings = { + ...defaultServerSettings[key], + ...persistedSettings, + } as ServerSettings[TKey]; + + if ( + key === "board" && + typeof (persistedSettings as Partial).enableAutomaticMobileLayout !== "boolean" + ) { + (mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout = + await getAutomaticMobileLayoutUpgradeDefaultAsync(db); + } + + return mergedSettings; }; export const updateServerSettingByKeyAsync = async ( @@ -46,7 +115,7 @@ export const updateServerSettingByKeyAsync = async { await db.insert(serverSettings).values({ settingKey: key, - value: SuperJSON.stringify(value), + value: stringify(value), }); }; diff --git a/packages/modals/src/confirm-modal.tsx b/packages/modals/src/confirm-modal.tsx index 9f3f2b4ac..28b0e72b0 100644 --- a/packages/modals/src/confirm-modal.tsx +++ b/packages/modals/src/confirm-modal.tsx @@ -16,6 +16,7 @@ export interface ConfirmModalProps { children: ReactNode; onConfirm?: () => MaybePromise; onCancel?: () => MaybePromise; + onClose?: () => void; closeOnConfirm?: boolean; closeOnCancel?: boolean; cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">; diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx index 4a5596070..dcf1cf51a 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -127,6 +127,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => { interface OpenModalOptions { keepMounted?: boolean; title?: stringOrTranslation; + onClose?: () => void; } export const useModalAction = (modal: TModal) => { @@ -147,6 +148,10 @@ export const useConfirmModal = () => { const { openModal } = useModalAction(ConfirmModal); return { - openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }), + openConfirmModal: (props: ConfirmModalProps) => + openModal(props, { + title: props.title, + onClose: props.onClose, + }), }; }; diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts new file mode 100644 index 000000000..7141572ba --- /dev/null +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -0,0 +1,50 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest"; + +import { createRequestHandler } from "./request-handler"; + +describe("createRequestHandler", () => { + test("keeps the standard result contract free of cache provenance", async () => { + const requestHandler = createRequestHandler({ + requestAsync: vi.fn().mockResolvedValue("current"), + }); + + const result = await requestHandler.handler({ integrationId: "first" }).getDataAsync(); + + expect(result).toMatchObject({ data: "current", timestamp: expect.any(Date) }); + expect(result).not.toHaveProperty("isStale"); + }); + + test("marks an expired cache fallback as stale while preserving its timestamp", async () => { + const requestAsync = vi.fn().mockResolvedValueOnce("cached").mockRejectedValueOnce(new Error("offline")); + const requestHandler = createRequestHandler({ + requestAsync, + cacheTtlMs: 0, + fallbackToStaleOnError: true, + }); + const handler = requestHandler.handler({ integrationId: "first" }); + + const fresh = await handler.getDataWithProvenanceAsync(); + const fallback = await handler.getDataWithProvenanceAsync(); + + expect(fresh).toMatchObject({ data: "cached", isStale: false }); + expect(fallback).toEqual({ + data: "cached", + timestamp: fresh.timestamp, + isStale: true, + }); + expect(requestAsync).toHaveBeenCalledTimes(2); + }); + + test("does not hide an initial error when no cache exists", async () => { + const requestHandler = createRequestHandler({ + requestAsync: vi.fn().mockRejectedValue(new Error("offline")), + fallbackToStaleOnError: true, + }); + + await expect(requestHandler.handler({ integrationId: "first" }).getDataWithProvenanceAsync()).rejects.toThrow( + "offline", + ); + }); +}); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index 4dbdb5b11..d45c4371d 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -5,6 +5,7 @@ interface Options> { } type CacheEntry = { data: TData; timestamp: Date; expiresAt: number }; +type RequestResult = Pick, "data" | "timestamp"> & { isStale: boolean }; const MAX_CACHE_SIZE = 1000; const DEFAULT_TTL_MS = 10_000; @@ -20,21 +21,21 @@ export const createRequestHandler = , ) => { const cache = new Map>(); - const inflight = new Map>>(); + const inflight = new Map>>(); return { invalidateCache: () => { cache.clear(); inflight.clear(); }, - handler: (input: TInput) => ({ - async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { + handler: (input: TInput) => { + const getDataWithProvenanceAsync = async (): Promise> => { const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; const key = JSON.stringify(input); const cached = cache.get(key); if (cached && Date.now() < cached.expiresAt) { - return { data: cached.data, timestamp: cached.timestamp }; + return { data: cached.data, timestamp: cached.timestamp, isStale: false }; } const existing = inflight.get(key); @@ -51,19 +52,27 @@ export const createRequestHandler = = { data, timestamp: new Date(), expiresAt: Date.now() + ttl }; cache.set(key, entry); inflight.delete(key); - return entry; + return { data: entry.data, timestamp: entry.timestamp, isStale: false }; }) .catch((err) => { inflight.delete(key); if (options.fallbackToStaleOnError && cached) { - return cached; + return { data: cached.data, timestamp: cached.timestamp, isStale: true }; } throw err; }); inflight.set(key, promise); return promise; - }, - }), + }; + + return { + async getDataAsync(): Promise, "data" | "timestamp">> { + const { data, timestamp } = await getDataWithProvenanceAsync(); + return { data, timestamp }; + }, + getDataWithProvenanceAsync, + }; + }, }; }; diff --git a/packages/request-handler/src/media-release.ts b/packages/request-handler/src/media-release.ts index 02209f68f..bc8320da3 100644 --- a/packages/request-handler/src/media-release.ts +++ b/packages/request-handler/src/media-release.ts @@ -13,4 +13,5 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMediaReleasesAsync(); }, + fallbackToStaleOnError: true, }); diff --git a/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts index a8a52ea99..4e94389fd 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -27,6 +27,7 @@ export const defaultServerSettings = { board: { homeBoardId: null as string | null, mobileHomeBoardId: null as string | null, + enableAutomaticMobileLayout: true, enableStatusByDefault: true, forceDisableStatus: false, }, diff --git a/packages/settings/src/creator.ts b/packages/settings/src/creator.ts index ee7dfaf6e..1333ff220 100644 --- a/packages/settings/src/creator.ts +++ b/packages/settings/src/creator.ts @@ -12,14 +12,14 @@ export type SettingsContextProps = Pick< | "pingIconsEnabled" | "enableRightClickOnWidgets" > & - Pick & + Pick & Pick; export interface PublicServerSettings { search: Pick; board: Pick< ServerSettings["board"], - "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" + "homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus" >; user: Pick; } @@ -51,6 +51,7 @@ export const createSettings = ({ mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, pingIconsEnabled: user?.pingIconsEnabled ?? false, enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true, + enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout, enableStatusByDefault: serverSettings.board.enableStatusByDefault, forceDisableStatus: serverSettings.board.forceDisableStatus, enableGravatar: serverSettings.user.enableGravatar, diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index b9c16aef8..59a28f725 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -24,6 +24,7 @@ "dependencies": { "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", + "@homarr/boards": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index fd61dc26e..1fe3d64b8 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -7,6 +7,7 @@ import type { DayOfWeek } from "@mantine/dates"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; +import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import type { ColorScheme } from "@homarr/definitions"; import { showErrorNotification } from "@homarr/notifications"; import type { SettingsContextProps } from "@homarr/settings/creator"; @@ -160,7 +161,7 @@ export const useUserPreferences = () => { const clientSetters: Partial void>> = { colorScheme: (value) => setColorScheme(value as ColorScheme), - locale: (value) => changeLocale(value as typeof currentLocale), + locale: (value) => requestBoardEditAction(() => changeLocale(value as typeof currentLocale)), fullPreferencesPage: () => undefined, }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index bfb275c07..525d8847e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1495,6 +1495,10 @@ "docker": "Docker", "preferences": "Your preferences", "logout": "Logout", + "logoutError": { + "title": "Logout failed", + "message": "Your session is still active. Please try again." + }, "login": "Login", "homeBoard": "Your home board", "loggedOut": "Logged out", @@ -4382,7 +4386,15 @@ "description": "Configure the layout used on desktop and larger screens." }, "mobile": { - "description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers." + "automaticDescription": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers." + }, + "responsive": { + "title": "Responsive layouts", + "description": "Automatic mobile layout is disabled for this server. Configure the saved layouts selected at each viewport breakpoint; these layouts can also be edited directly on mobile.", + "defaultName": "New layout", + "action": { + "add": "Add layout" + } } }, "background": { @@ -4832,6 +4844,13 @@ "mobileLabel": "Global mobile board", "description": "Only public boards are available for selection" }, + "mobileLayout": { + "title": "Mobile layout", + "enable": { + "label": "Use automatic mobile layout", + "description": "Arrange every board into a read-only two-column layout on phones and small tablets. Turn this off to use saved responsive layouts and mobile layout editing. Saved responsive layouts are kept when this is enabled." + } + }, "status": { "title": "App status", "enableStatusByDefault": { diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index e16b4eb11..c68085bf0 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -24,7 +24,7 @@ export const boardNameSchema = z .min(1) .max(255) .regex(/^[A-Za-z0-9-_]*$/); -export const boardColumnCountSchema = z.number().min(1).max(24); +export const boardColumnCountSchema = z.number().int().min(1).max(24); export const boardByNameSchema = z.object({ name: boardNameSchema, @@ -75,6 +75,46 @@ export const boardSaveLayoutSchema = z.object({ columnCount: boardColumnCountSchema, }); +const responsiveBoardLayoutsSchema = z + .array( + z.object({ + id: z.string(), + name: z.string().trim().nonempty().max(32), + columnCount: boardColumnCountSchema, + breakpoint: z.number().int().min(0).max(32767), + }), + ) + .min(1) + .superRefine((layouts, context) => { + const seenBreakpoints = new Set(); + const seenIds = new Set(); + + layouts.forEach((layout, index) => { + if (seenIds.has(layout.id)) { + context.addIssue({ + code: "custom", + message: "Each responsive layout must have a unique ID", + path: [index, "id"], + }); + } + seenIds.add(layout.id); + + if (seenBreakpoints.has(layout.breakpoint)) { + context.addIssue({ + code: "custom", + message: "Each responsive layout must have a unique breakpoint", + path: [index, "breakpoint"], + }); + } + seenBreakpoints.add(layout.breakpoint); + }); + }); + +export const boardSaveLayoutsSchema = z.object({ + id: z.string(), + layouts: responsiveBoardLayoutsSchema, +}); + export const boardSaveSchema = z.object({ id: z.string(), sections: z.array(sectionSchema), diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index e6b7eb7c0..7a628649f 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -7,13 +7,16 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; +import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; +import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import classes from "./bookmark.module.css"; -export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) { +export default function BookmarksWidget({ options, itemId, displayMode }: WidgetComponentProps<"bookmarks">) { const board = useRequiredBoard(); + const t = useI18n(); const { data = [] } = clientApi.app.byIds.useQuery(options.items, { select(data) { return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); @@ -41,6 +44,21 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp [data], ); + if (displayMode === "mobileSummary") { + return ( + app.name) + .slice(0, 2) + .join(" · ") || t("board.mobile.details") + } + /> + ); + } + return ( {options.title.length > 0 && ( diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 685983859..5234eb8fc 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -10,8 +10,13 @@ import { BookmarkAddButton } from "./add-button"; export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { icon: IconBookmark, - mobile: { supportsDetailView: true }, - queryKey: [["app"]], + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, + queryKey: [["app", "byIds"]], createOptions() { return optionsBuilder.from((factory) => ({ title: factory.text(), diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index ccb07e888..ae9ea51b9 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -14,6 +14,7 @@ import { useSettings } from "@homarr/settings"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; import { CalendarDay } from "./calender-day"; import classes from "./component.module.css"; @@ -44,8 +45,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input); const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !data) throw error; + if (isPending) return ; + throwOnInitialQueryError(error, data !== undefined); return ( ); }; @@ -125,7 +130,7 @@ const CalendarBase = ({ ); } - return ( + const calendar = ( setMonth(new Date(month))} @@ -205,6 +210,7 @@ const CalendarBase = ({ }} /> ); + return {calendar}; }; /** diff --git a/packages/widgets/src/common/query-state.spec.ts b/packages/widgets/src/common/query-state.spec.ts new file mode 100644 index 000000000..e724b3e66 --- /dev/null +++ b/packages/widgets/src/common/query-state.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; + +import { hasWidgetDataWarning, throwOnInitialQueryError } from "./query-state"; + +describe("widget query state", () => { + test.each([ + { error: new Error("refresh failed") }, + { failedIntegrationCount: 1 }, + { staleIntegrationCount: 1 }, + { expectedIntegrationCount: 2, receivedIntegrationCount: 1 }, + ])("warns when data provenance is incomplete or stale", (provenance) => { + expect(hasWidgetDataWarning(provenance)).toBe(true); + }); + + test("does not warn for complete current data", () => { + expect( + hasWidgetDataWarning({ + failedIntegrationCount: 0, + staleIntegrationCount: 0, + expectedIntegrationCount: 2, + receivedIntegrationCount: 2, + }), + ).toBe(false); + }); + + test("throws an initial query error", () => { + const error = new Error("initial request failed"); + expect(() => throwOnInitialQueryError(error, false)).toThrow(error); + }); + + test("keeps cached data visible after a refresh error", () => { + expect(() => throwOnInitialQueryError(new Error("refresh failed"), true)).not.toThrow(); + }); +}); diff --git a/packages/widgets/src/common/query-state.tsx b/packages/widgets/src/common/query-state.tsx new file mode 100644 index 000000000..2347a8b57 --- /dev/null +++ b/packages/widgets/src/common/query-state.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Box, Center, Tooltip } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface WidgetQueryProvenance { + error?: unknown; + failedIntegrationCount?: number; + staleIntegrationCount?: number; + expectedIntegrationCount?: number; + receivedIntegrationCount?: number; +} + +export const hasWidgetDataWarning = ({ + error, + failedIntegrationCount = 0, + staleIntegrationCount = 0, + expectedIntegrationCount, + receivedIntegrationCount, +}: WidgetQueryProvenance) => + Boolean(error) || + failedIntegrationCount > 0 || + staleIntegrationCount > 0 || + (expectedIntegrationCount !== undefined && + receivedIntegrationCount !== undefined && + receivedIntegrationCount < expectedIntegrationCount); + +export const throwOnInitialQueryError = (error: unknown, hasData: boolean) => { + if (error && !hasData) throw error; +}; + +interface WidgetDataStateProps { + children: ReactNode; + hasWarning: boolean; +} + +export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) { + const t = useI18n(); + const label = t("board.mobile.dataWarning"); + + return ( + + {hasWarning && ( + +
+ +
+
+ )} + {children} +
+ ); +} diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts index 90ee7bef7..6608080b5 100644 --- a/packages/widgets/src/custom-api/index.ts +++ b/packages/widgets/src/custom-api/index.ts @@ -6,7 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("customApi", { icon: IconApi, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "customApi"]], createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 553116564..9639e6c78 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -9,7 +9,7 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { icon: IconDeviceGamepad, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index 0d605e8e9..1e6f813f9 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -9,6 +9,7 @@ export const widgetKind = "dnsHoleSummary"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { icon: IconAd, + mobile: { unmountWhenOffscreen: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index cb4f0ddf1..b00cb44f9 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -50,6 +50,7 @@ import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/i import { useScopedI18n } from "@homarr/translation/client"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; dayjs.extend(relativeTime); @@ -129,7 +130,7 @@ const DownloadClientsMobileSummary = ({ }); if (isPending) return ; - if (error && !data) throw error; + throwOnInitialQueryError(error, data !== undefined); const currentItems = data ?? []; const selectedClients = currentItems.filter(({ integration }) => integrationIds.includes(integration.id)); @@ -148,7 +149,11 @@ const DownloadClientsMobileSummary = ({ value={visibleItemCount} label={t("name")} description={`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`} - isStale={Boolean(error) || currentItems.length < integrationIds.length} + isStale={hasWidgetDataWarning({ + error, + expectedIntegrationCount: integrationIds.length, + receivedIntegrationCount: currentItems.length, + })} /> ); }; @@ -163,10 +168,11 @@ const DownloadClientsTable = ({ integrationIds.includes(id) ? [id] : [], ); - const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ + const { data: queryData, error: queryError } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ integrationIds, limitPerIntegration: options.limitPerIntegration, }); + const currentItems = queryData ?? []; const t = useScopedI18n("widget.downloads"); const tCommon = useScopedI18n("common"); @@ -694,15 +700,24 @@ const DownloadClientsTable = ({ { up: 0, down: 0 }, ); + throwOnInitialQueryError(queryError, queryData !== undefined); + const hasWarning = hasWidgetDataWarning({ + error: queryError, + expectedIntegrationCount: integrationIds.length, + receivedIntegrationCount: currentItems.length, + }); + if (options.columns.length === 0) return ( -
- {t("errors.noColumns")} -
+ +
+ {t("errors.noColumns")} +
+
); //The actual widget - return ( + const content = ( ); + return {content}; }; interface ItemInfoModalProps { diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index da05f89df..44244f07c 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -7,7 +7,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("firewall", { icon: IconWall, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "firewall"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 948f7f987..79f6ed516 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -16,6 +16,7 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; export default function MediaReleasesWidget({ @@ -23,29 +24,33 @@ export default function MediaReleasesWidget({ integrationIds, displayMode, }: WidgetComponentProps<"mediaReleases">) { - const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ + const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleasesWithProvenance.useQuery({ integrationIds, }); const t = useI18n("widget.mediaReleases"); - if (displayMode === "mobileSummary") { - if (isPending) return ; - if (error && !data) throw error; + if (isPending) return ; + throwOnInitialQueryError(error, data !== undefined); - const releases = data ?? []; + const releases = data?.items ?? []; + const hasWarning = hasWidgetDataWarning({ + error, + failedIntegrationCount: data?.failedIntegrationCount, + staleIntegrationCount: data?.staleIntegrationCount, + }); + + if (displayMode === "mobileSummary") { return ( ); } - const releases = data ?? []; - - return ( + const content = ( {releases.map((item, index) => ( @@ -55,10 +60,11 @@ export default function MediaReleasesWidget({ ))} ); + return {content}; } interface ItemProps { - item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number]; + item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleasesWithProvenance"]["items"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; } diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 6ec502dec..1ee3dafd0 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -27,6 +27,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../../common/query-state"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "../search-button.module.css"; @@ -39,11 +40,7 @@ export default function MediaServerWidget({ displayMode, }: WidgetComponentProps<"mediaRequests-requestList">) { const t = useScopedI18n("widget.mediaRequests-requestList"); - const { - data: mediaRequests, - error, - isPending, - } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ + const { data, error, isPending } = clientApi.widget.mediaRequests.getLatestRequestsWithProvenance.useQuery({ integrationIds, statuses: options.statusFilter.length > 0 @@ -52,23 +49,38 @@ export default function MediaServerWidget({ recentDays: options.recentDays, }); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !mediaRequests) throw error; - if (!mediaRequests) return ; - if (mediaRequests.length === 0) throw new NoIntegrationDataError(); + if (isPending) return ; + throwOnInitialQueryError(error, data !== undefined); + if (!data) return ; + + const hasWarning = hasWidgetDataWarning({ + error, + failedIntegrationCount: data.failedIntegrationCount, + staleIntegrationCount: data.staleIntegrationCount, + }); + + if (data.items.length === 0 && !hasWarning) throw new NoIntegrationDataError(); if (displayMode === "mobileSummary") { return ( ); } - return ( + if (data.items.length === 0) { + return ( + + + + ); + } + + const content = ( {!isEditMode && } - {mediaRequests.map((mediaRequest) => ( + {data.items.map((mediaRequest) => ( ); + return {content}; } const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { @@ -110,7 +123,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }; interface MediaRequestCardProps { - request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; + request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequestsWithProvenance"]["items"][number]; isTiny: boolean; options: WidgetComponentProps<"mediaRequests-requestList">["options"]; } diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 5348b845a..49f586f74 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -22,6 +22,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; +import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../../common/query-state"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "./component.module.css"; @@ -34,16 +36,30 @@ export default function MediaServerWidget({ integrationIds, isEditMode, width, + displayMode, }: WidgetComponentProps<"mediaRequests-requestStats">) { const t = useScopedI18n("widget.mediaRequests-requestStats"); - const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({ + const { + data: requestStats, + error, + isPending, + } = clientApi.widget.mediaRequests.getStatsWithProvenance.useQuery({ integrationIds, }); const board = useRequiredBoard(); + if (isPending) return ; + throwOnInitialQueryError(error, requestStats !== undefined); if (!requestStats) return ; - if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError(); + const hasWarning = hasWidgetDataWarning({ + error, + failedIntegrationCount: requestStats.failedIntegrationCount, + staleIntegrationCount: requestStats.staleIntegrationCount, + }); + if (requestStats.users.length === 0 && requestStats.stats.length === 0 && !hasWarning) { + throw new NoIntegrationDataError(); + } const data = [ { @@ -88,9 +104,23 @@ export default function MediaServerWidget({ }, ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; + if (displayMode === "mobileSummary") { + const total = data.find((stat) => stat.name === "total")?.number ?? 0; + const pending = data.find((stat) => stat.name === "pending")?.number ?? 0; + + return ( + + ); + } + const isTiny = width < 256; - return ( + const content = ( {!isEditMode && } ); + return {content}; } const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index d215eabac..991465d54 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -7,7 +7,12 @@ import { createWidgetDefinition } from "../../definition"; export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { icon: IconChartBar, - mobile: { supportsDetailView: true }, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, queryKey: [["widget", "mediaRequests"]], createOptions() { return {}; diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 361b8ab7b..ad723d650 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -25,6 +25,7 @@ import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; export default function MediaServerWidget({ displayMode, ...props }: WidgetComponentProps<"mediaServer">) { @@ -43,10 +44,9 @@ const MediaServerMobileSummary = ({ }); if (isPending) return ; - if (error && !data) throw error; + throwOnInitialQueryError(error, data !== undefined); const currentStreams = data ?? []; - const hasPartialFailure = currentStreams.length < integrationIds.length; const playingSessions = currentStreams .flatMap((stream) => stream.sessions) .filter((session) => session.currentlyPlaying !== null); @@ -56,7 +56,11 @@ const MediaServerMobileSummary = ({ value={playingSessions.length} label={t("items.currentlyPlaying")} description={playingSessions[0]?.currentlyPlaying?.name} - isStale={Boolean(error) || hasPartialFailure} + isStale={hasWidgetDataWarning({ + error, + expectedIntegrationCount: integrationIds.length, + receivedIntegrationCount: currentStreams.length, + })} /> ); }; @@ -65,10 +69,11 @@ const MediaServerTable = ({ options, integrationIds, }: Pick, "options" | "integrationIds">) => { - const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ + const { data: queryData, error } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, showOnlyPlaying: options.showOnlyPlaying, }); + const currentStreams = queryData ?? []; const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( @@ -292,8 +297,14 @@ const MediaServerTable = ({ integrationName: session?.integrationName, }; }); + throwOnInitialQueryError(error, queryData !== undefined); + const hasWarning = hasWidgetDataWarning({ + error, + expectedIntegrationCount: integrationIds.length, + receivedIntegrationCount: currentStreams.length, + }); - return ( + const content = ( ); + return {content}; }; const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index ea8cbd466..0ffc3cc1b 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -10,6 +10,7 @@ import { useTimeAgo } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; export default function NotificationsWidget({ @@ -38,17 +39,20 @@ export default function NotificationsWidget({ [notificationIntegrations], ); - if (displayMode === "mobileSummary") { - if (isPending) return ; - if (error && !notificationIntegrations) throw error; + if (isPending) return ; + throwOnInitialQueryError(error, notificationIntegrations !== undefined); + const hasWarning = hasWidgetDataWarning({ + error, + expectedIntegrationCount: integrationIds.length, + receivedIntegrationCount: notificationIntegrations?.length, + }); - const notifications = notificationIntegrations ?? []; + if (displayMode === "mobileSummary") { const latestNotification = sortedNotifications[0]; - const isStale = Boolean(error) || notifications.length < integrationIds.length; const summary = latestNotification ? ( - + ) : ( - + ); if (!latestNotification?.href) return summary; @@ -68,7 +72,7 @@ export default function NotificationsWidget({ ); } - return ( + const content = ( {sortedNotifications.length > 0 ? ( @@ -117,6 +121,7 @@ export default function NotificationsWidget({ ); + return {content}; } const NotificationMobileSummary = ({ diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index c3bb857ce..c6142c3d5 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -5,7 +5,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("tracearr", { icon: IconActivityHeartbeat, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "tracearr"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts index 974a3088f..3bc4a9a43 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -8,7 +8,7 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth export const { definition, componentLoader } = createWidgetDefinition("umami", { icon: IconChartBar, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "umami"]], refetchInterval: null, supportedIntegrations: ["umami"], diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 78b66ef11..01dd62f49 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -11,6 +11,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; @@ -117,11 +118,15 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds, }); - const hasPartialFailure = (dashboardData?.length ?? 0) < integrationIds.length; - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !dashboardData) throw error; + if (isPending) return ; + throwOnInitialQueryError(error, dashboardData !== undefined); if (!dashboardData) return ; + const hasWarning = hasWidgetDataWarning({ + error, + expectedIntegrationCount: integrationIds.length, + receivedIntegrationCount: dashboardData.length, + }); const combined = dashboardData.reduce( (acc, item) => ({ @@ -163,13 +168,13 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti value={formatNumber(combined[primaryStat], 0)} label={t(`stats.${primaryStat}`)} description={supportingDescription} - isStale={Boolean(error) || hasPartialFailure} + isStale={hasWarning} /> ); } if (!hasContent) { - return ; + return ; } return ( @@ -177,7 +182,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti value={`${formatNumber(uptimeValue, 1)}%`} label={t("averageUptime")} description={supportingDescription} - isStale={Boolean(error) || hasPartialFailure} + isStale={hasWarning} /> ); } @@ -186,7 +191,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing]; - return ( + const content = (
{showHero && (
@@ -241,6 +246,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti )}
); + return {content}; } function clampPercent(value: number): number { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7fafc619..d9dca5084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1427,6 +1427,9 @@ importers: '@homarr/api': specifier: workspace:^0.1.0 version: link:../api + '@homarr/settings': + specifier: workspace:^0.1.0 + version: link:../settings react: specifier: 'catalog:' version: 19.2.7 @@ -2328,6 +2331,9 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../auth + '@homarr/boards': + specifier: workspace:^0.1.0 + version: link:../boards '@homarr/common': specifier: workspace:^0.1.0 version: link:../common