diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2bcbd140a0..21eec82b8e 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -110,6 +110,8 @@ declare module '@tanstack/react-router' { icon?: LucideIcon; /** Render the route with minimal chrome (no page header/footer/padding). */ fullscreen?: boolean; + /** Route has its own title bar: the app header shows only the breadcrumb row. */ + breadcrumbOnlyHeader?: boolean; } } diff --git a/frontend/src/components/layout/footer.tsx b/frontend/src/components/layout/footer.tsx index ddaa52f8bf..0707ec0e65 100644 --- a/frontend/src/components/layout/footer.tsx +++ b/frontend/src/components/layout/footer.tsx @@ -11,6 +11,7 @@ import { useLocation, useMatchRoute } from '@tanstack/react-router'; import { GitHubIcon, SlackIcon, TwitterIcon } from 'components/icons'; +import { useLayoutEffect, useRef } from 'react'; import { isEmbedded } from '../../config'; import env, { getBuildDate, IsCI, IsDev } from '../../utils/env'; @@ -54,17 +55,87 @@ export const VersionInfo = () => { ); }; +/** Distance from the document top to the element, scroll-independent. */ +const measureDocumentTop = (target: HTMLElement): number => { + let top = 0; + for (let el: HTMLElement | null = target; el; ) { + top += el.offsetTop; + el = el.offsetParent instanceof HTMLElement ? el.offsetParent : null; + } + return top; +}; + +/** Space the ancestors (up to and including
) keep below the element. */ +const measureReservedBelow = (target: HTMLElement): number => { + let reserved = 0; + for (let el: HTMLElement | null = target; el && el !== document.documentElement; el = el.parentElement) { + const style = getComputedStyle(el); + reserved += Number.parseFloat(style.marginBottom) || 0; + if (el !== target) { + reserved += (Number.parseFloat(style.paddingBottom) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0); + } + } + return reserved; +}; + +// Measured rather than a CSS flex chain: in embedded mode the wrappers above +// #mainLayout belong to the host app. +const stretchLayoutToViewportBottom = (layoutEl: HTMLElement) => { + const viewportHeight = document.documentElement.clientHeight; + const minHeight = Math.round(viewportHeight - measureDocumentTop(layoutEl) - measureReservedBelow(layoutEl)); + const value = minHeight > 0 ? `${minHeight}px` : ''; + if (layoutEl.style.minHeight !== value) { + layoutEl.style.minHeight = value; + } +}; + +/** Pins the footer to the viewport bottom on short pages by stretching `#mainLayout`. */ +const useBottomPinnedFooter = (hidden: boolean) => { + const footerRef = useRef