diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index fda610981..451586e1a 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -81,8 +81,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - 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, 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 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. +because omitting a layout would remove it; configure them through Homarr's board settings UI when a server uses +legacy mobile layout mode. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index e004d75bd..990d58f9c 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -34,7 +34,7 @@ Underscores and hyphen-minus are allowed. ### Column count -Homarr has a complex drag and drop system that can be used from the browser. +Homarr has a complex drag-and-drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public @@ -270,7 +270,7 @@ Alternatively, you can also define it via your user profile: ### For whole server -You can also set a global home board that will be used for all users that have not set a home board themselves. +You can also set a global home board that will be used for all users who have not set a home board themselves. It will also be used for anonymous users. 1. Navigate to the settings: `Management` > `Settings` > `Boards` diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 1142ed3fb..308e7cd26 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -26,5 +26,5 @@ "**/*.md", "**/*.mdx", ], - "exclude": ["node_modules"], + "exclude": ["node_modules", "build", ".docusaurus"], } diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 4ff31b9aa..491cfa4c5 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -5,6 +5,7 @@ import "@homarr/common/env"; import "@homarr/core/infrastructure/logs/env"; import "@homarr/docker/env"; +import path from "node:path"; import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; @@ -34,6 +35,10 @@ const nextConfig: NextConfig = { webpackMemoryOptimizations: true, }, turbopack: { + // Keep Turbopack and output-file tracing inside this monorepo. Without an + // explicit root, an unrelated parent lockfile can make Next.js scan the + // entire parent directory and stall production builds. + root: path.join(import.meta.dirname, "../.."), // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x // (see vercel/next.js#95125). Suppress until upstream fix lands. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts new file mode 100644 index 000000000..d96d80f09 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { classifyGuardedNavigation, removeGuardHistoryEntry, replaceGuardedSameDocumentUrl } from "./_guarded-history"; + +describe("guarded board edit history", () => { + beforeEach(() => { + window.history.replaceState({ page: "board" }, "", "/boards/demo"); + }); + + test("replaces the guard entry for same-document fragment navigation", () => { + const section = document.createElement("section"); + section.id = "Media requests"; + document.body.appendChild(section); + const scrollIntoView = vi.fn(); + Object.defineProperty(section, "scrollIntoView", { value: scrollIntoView }); + + replaceGuardedSameDocumentUrl(new URL("/boards/demo#Media%20requests", window.location.href), { + key: "__guard", + id: "guard-id", + }); + + expect(window.location.hash).toBe("#Media%20requests"); + expect(window.history.state).toStrictEqual({ + page: "board", + __guard: "guard-id", + }); + expect(scrollIntoView).toHaveBeenCalledOnce(); + section.remove(); + }); + + test("treats an explicit empty fragment as same-document navigation", () => { + const current = new URL(window.location.href); + const destination = new URL("#", current); + + expect(classifyGuardedNavigation(destination, current)).toBe("same-document"); + }); + + test("removes the guard while preserving the fragment when editing ends", () => { + window.history.replaceState({ page: "board", __guard: "guard-id" }, "", "/boards/demo#section"); + const back = vi.spyOn(window.history, "back").mockImplementation(() => { + window.history.replaceState({ page: "board" }, "", "/boards/demo"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + + removeGuardHistoryEntry("http://localhost:3000/boards/demo#section", "__guard"); + + expect(back).toHaveBeenCalledOnce(); + expect(window.location.href).toBe("http://localhost:3000/boards/demo#section"); + expect(window.history.state).toStrictEqual({ page: "board" }); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts new file mode 100644 index 000000000..29677f8e1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts @@ -0,0 +1,58 @@ +const getHistoryState = () => + window.history.state && typeof window.history.state === "object" + ? (window.history.state as Record) + : {}; + +interface GuardState { + key: string; + id: string; +} + +export const classifyGuardedNavigation = (destination: URL, current: URL) => { + if (destination.href === current.href) return "same-url"; + if ( + destination.origin === current.origin && + destination.pathname === current.pathname && + destination.search === current.search + ) { + return "same-document"; + } + return "other-document"; +}; + +export const replaceGuardedSameDocumentUrl = (destination: URL, guard: GuardState) => { + window.history.replaceState( + { + ...getHistoryState(), + [guard.key]: guard.id, + }, + document.title, + destination.href, + ); + + if (!destination.hash) { + window.scrollTo({ top: 0, left: 0 }); + return; + } + + let targetId = destination.hash.slice(1); + try { + targetId = decodeURIComponent(targetId); + } catch { + // Keep the encoded value when the fragment contains malformed escape sequences. + } + document.getElementById(targetId)?.scrollIntoView(); +}; + +export const removeGuardHistoryEntry = (guardedUrl: string, guardStateKey: string) => { + window.addEventListener( + "popstate", + () => { + const state = { ...getHistoryState() }; + delete state[guardStateKey]; + window.history.replaceState(state, document.title, guardedUrl); + }, + { once: true }, + ); + window.history.back(); +}; 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 56cf5d6e7..c3851055d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -55,6 +55,8 @@ import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-co import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { HeaderButton } from "~/components/layout/header/button"; +import { classifyGuardedNavigation, removeGuardHistoryEntry, replaceGuardedSameDocumentUrl } from "./_guarded-history"; + export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); @@ -74,9 +76,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool return ( <> - {isEditMode && !isMobile && } + {isEditMode && } -