wrolpi/app/src/components/Nav.js
lrnselfreliance eaa8b83e54
ui: the navbar shows which section you are in (#649)
* ui: the navbar shows which section you are in

Clicking Videos opened the videos page and left the bar looking untouched.

react-router was doing its part the whole time -- the tab carried `active` and
`aria-current="page"` on every one of those pages.  Nothing in the stylesheet
drew that class, so all eleven tabs rendered identically and the mark was
visible only to a screen reader.

The current tab now takes a deepened version of the hover wash plus an
underline.  The underline is a pseudo-element in `currentColor` rather than a
border, for two reasons: `currentColor` is the foreground the bar already
computed for whichever of the fourteen navbar colors the user picked, so one
rule covers every color in every theme without naming one; and a pseudo-element
adds no height, where a border would jog the whole row as the mark moved
between tabs.  The overflow "More" menu and the mobile hamburger mark their
copy of the tab too -- on a phone that menu is where most tabs live.

Calculators and Statistics also lose `end: true`.  A tab is meant to stay
current for the pages beneath it, and `end` restricts the match to the exact
path, so either one would have dropped its mark the moment it grew a sub-page.
The other nine links never had it, which is why /videos/1234 already kept
Videos marked.

The test renders the real bar, which needed two pieces of browser jsdom does
not have: fresnel renders no navbar at all unless a breakpoint query matches,
and every width is 0, which convinces the overflow logic that nothing fits and
sweeps all eleven tabs into the closed "More" menu -- where every assertion
about them would have passed vacuously.  Both are stubbed, and the styling
itself is asserted against the stylesheet, since jsdom applies none.

* review: the mark follows the tab into the overflow menu

Six review points, no bugs among them.  The substantive one: when the window
narrows enough to fold the current tab into "More", the bar went blank again --
the trigger is a plain button, not a NavLink, so nothing was marked until the
user opened the menu to look, which is exactly when they least need telling.
"More" now takes the mark when it holds the current section.

That needs the matching rule outside a NavLink, where the hooks that answer it
cannot be called in a loop, so `isLinkActive` states it once: a link owns the
pages beneath it unless it asks for an exact match.  The trigger gets the class
only -- the real NavLink inside the menu already carries `aria-current`, and a
second one on the button that opens the menu announces the same page twice.

The other five, all fair:

  * The mobile hamburger and the overflow menu had no test at all, so
    `.mantine-Menu-item.active` -- the whole of the mark on a phone -- was
    never exercised.  Both now are, which meant splitting the viewport and the
    bar width out of the fixture so a test can ask for a narrow bar or a phone.
    The mobile case also needs the real getBoundingClientRect back: floating-ui
    positions the menu's portal and crashes the worker on a stub DOMRect.
  * The CSS guard matched the selector as a substring, which still passed with
    the rule body emptied or the underline deleted -- and the underline is the
    half that survives on a dark bar.  It now reads the declarations.
  * The table cases used `toContain`, which a regression leaving Home
    permanently marked would satisfy on every row.  `toEqual([text])`.
  * A comment claimed the tab "only matched its own exact path", which is the
    root cause the PR description explicitly rules out -- Videos never carried
    `end`.  Corrected to what those cases actually guard.
  * The navbar offers twelve colors, not fourteen.  I had carried over
    Semantic's palette count, which includes black and white.

Writing the tightened height assertion I reached for `(?!0)`, which calls
0.1875rem zero.  It reads the number instead.
2026-08-08 18:05:55 -06:00

496 lines
22 KiB
JavaScript

import React from "react";
import {Link, NavLink, useLocation} from "react-router";
import {IconMenu2, IconPlugOff, IconTemperature, IconTemperaturePlus} from "@tabler/icons-react";
import {Icon, IconButton, Menu, Tooltip} from "./ui";
import {Media, SettingsContext, StatusContext} from "../contexts/contexts";
import {DarkModeToggle, HotspotStatusIcon, useLocalStorage} from "./Common";
import {ShareButton} from "./Share";
import {
useCPUTemperature,
useDriveHealth,
useDriveTemperature,
useIOStats,
useLoad,
useMemoryStats,
usePowerStats,
useWROLMode
} from "../hooks/customHooks";
import {useReorganizationStatus} from "../contexts/FileWorkerStatusContext";
import {SearchIconButton} from "./Search";
import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars";
import {useOverflowNav} from "../hooks/useOverflowNav";
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
import _ from "lodash";
function updateFavicon(colorName) {
// Fall back to violet if invalid color
const safeColor = colorName in navColorHexMap ? colorName : 'violet';
const faviconPath = `/favicon-${safeColor}.svg`;
// Update all favicon links
const iconLinks = document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]');
iconLinks.forEach(link => {
link.href = faviconPath;
});
// Also update the 32x32 and 16x16 specific icons if they exist
const icon32 = document.querySelector('link[sizes="32x32"]');
const icon16 = document.querySelector('link[sizes="16x16"]');
if (icon32) icon32.href = faviconPath;
if (icon16) icon16.href = faviconPath;
// Update theme-color meta tag for iOS/Android status bar
const hexColor = navColorHexMap[safeColor];
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) themeColorMeta.content = hexColor;
}
// A theme token name, or null to inherit the surrounding text/icon color (never
// literally "var(--null)").
const colorVar = (color) => color ? `var(--${color})` : undefined;
const help = {to: HELP_VIEWER_URI, text: 'Help', key: 'help', target: '_blank'};
const admin = {to: '/admin', text: 'Admin', key: 'admin'};
const rightLinks = [help, admin];
const allLinks = [
{text: 'Videos', to: '/videos', key: 'videos'},
{text: 'Archive', to: '/archives', key: 'archive'},
{text: 'Docs', to: '/docs', key: 'docs'},
{text: 'Map', to: '/map', key: 'map'},
{text: 'Files', to: '/files', key: 'files'},
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
// drop the mark the moment either grows a sub-page.
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},
{text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/statistics', text: 'Statistics', key: 'statistics'},
];
function MenuLink({link}) {
// A top-level tab rendered directly in the bar (not inside a dropdown).
if (link.links) {
return <DropdownLinks link={link}/>
}
const end = link.end ? {end: true} : {};
const target = link.target ? {target: link.target, rel: 'noopener noreferrer'} : {};
return <NavLink
className='wrolpi-navbar-link'
to={link.to}
{...end}
{...target}
>
{link.text}
</NavLink>
}
function DropdownMenuItem({link}) {
// An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop
// "More" overflow menu.
if (link.links) {
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
// indented group stands in for one. No current data uses this path (no
// allLinks entry has its own `links`).
return <React.Fragment>
<Menu.Label>{link.text}</Menu.Label>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
</React.Fragment>
}
const end = link.end ? {end: true} : {};
const target = link.target ? {target: link.target, rel: 'noopener noreferrer'} : {};
return <Menu.Item component={NavLink} to={link.to} {...end} {...target}>
{link.text}
</Menu.Item>
}
/*
* The trigger for a navbar dropdown.
*
* Shared with the hidden placeholder DesktopNav measures on its first render, and that is
* the point of extracting it. The placeholder used to be a plain `<button>More</button>`
* with no caret, so the space reserved for the overflow menu was about 21px short of the
* button that eventually rendered -- the row overflowed by that much and the bar wrapped.
* Measuring one thing and drawing another is only ever right by coincidence.
*/
const NavDropdownTrigger = React.forwardRef(({text, className, ...props}, ref) =>
/*
* `className` is pulled out and MERGED, never spread over. Menu.Target clones its child
* and hands it a className of its own, and a `{...props}` spread placed after the
* attribute replaces ours with it -- which left the real More button with no class at
* all, so it rendered as a bare grey UA button instead of taking the bar's color, its
* padding and its hover. The hidden placeholder is not cloned by Menu.Target, so it
* kept its class and measured wider than the button that actually drew.
*/
<button type='button' ref={ref}
className={['wrolpi-navbar-link', 'wrolpi-navbar-link-button', className]
.filter(Boolean).join(' ')}
{...props}>
{text}
<Icon name='dropdown' size='small' style={{marginLeft: '0.35em'}}/>
</button>
);
NavDropdownTrigger.displayName = 'NavDropdownTrigger';
/**
* Whether `link` covers `pathname`, matching NavLink's own rule: a link owns the pages
* beneath it unless it asks to match its path exactly.
*
* Duplicated from NavLink deliberately. The question here is asked about a link that is
* NOT rendered -- one hidden inside the overflow menu -- so there is no NavLink to ask, and
* the hooks that would answer it cannot be called in a loop.
*/
export const isLinkActive = (pathname, link) => {
// An off-site link (Help) is never the current section.
if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false;
const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to;
if (link.end || to === '/') return pathname === to;
return pathname === to || pathname.startsWith(`${to}/`);
}
function DropdownLinks({link}) {
// A labelled dropdown trigger: the desktop "More" overflow, or any nested
// link group handed to MenuLink.
const {pathname} = useLocation();
/*
* Mark the trigger when the current section is one of the links folded inside it.
*
* Without this the bar goes blank the moment the window narrows enough to push the
* current tab into "More" -- the mark exists, but only after the user opens the menu
* to look for it, which is precisely when they least need telling.
*
* The class only, not `aria-current`: the real NavLink inside the menu already carries
* that, and a second "current" on the button that opens the menu would announce the
* same page twice.
*/
const holdsCurrent = (link.links || []).some(child => isLinkActive(pathname, child));
return <Menu position='bottom-end' withinPortal>
<Menu.Target>
<NavDropdownTrigger text={link.text} className={holdsCurrent ? 'active' : undefined}/>
</Menu.Target>
<Menu.Dropdown>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
</Menu.Dropdown>
</Menu>
}
function MobileMenu({links}) {
// The mobile hamburger menu: an icon-only trigger holding every link.
return <Menu position='bottom-end' withinPortal>
<Menu.Target>
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
</Menu.Target>
<Menu.Dropdown>
{links.map(link => <DropdownMenuItem key={link.key} link={link}/>)}
</Menu.Dropdown>
</Menu>
}
/*
* One status indicator's slot in the right-hand corner.
*
* This used to carry `marginTop: 0.8em`, a nudge inherited from the bar this replaced. The
* corner centres its children, and a top margin is part of the margin box being centred, so the
* nudge pushed every indicator down by half of it -- about 6px.
*
* That read as an error on some and not others, which is why it survived: the bare-icon
* anchors (share, search) sit high inside their line box because it reserves descender space
* for text that is not there, so the nudge happened to cancel out. An IconButton is a square
* ActionIcon that centres its glyph exactly, so for the hotspot and the theme picker the
* nudge was 6px of pure error. Laying the slot out as a flex box centres both shapes on
* their real glyph box and needs no nudge at all.
*/
export function NavIconWrapper({children}) {
if (children) {
return <div className='wrolpi-navbar-icon'>{children}</div>
} else {
// Do not use navbar space if children is empty.
return <React.Fragment/>
}
}
function useNavColorSetting() {
// Use localstorage to avoid flickering navbar color on startup.
const {settings} = React.useContext(SettingsContext);
const [navColor, setNavColor] = useLocalStorage('nav_color', defaultNavColor);
React.useEffect(() => {
if (!_.isEmpty(settings)) {
setNavColor(settings.nav_color);
}
}, [settings.nav_color]);
// Update favicon to match nav color
React.useEffect(() => {
if (navColor) {
updateFavicon(navColor);
}
}, [navColor]);
return navColor
}
export function NavBar() {
const wrolModeEnabled = useWROLMode();
const {status} = React.useContext(StatusContext);
const navColor = useNavColorSetting();
/*
* The bar's foreground is measured against the background the theme resolved for the
* user's color, not taken from a single token. See themes/navColors.
*/
const navColors = useNavColors(navColor);
const wrolpiIcon = <img src='/icon.svg' height='32px' width='32px' alt='WROLPi Home Icon'/>;
const name = <i>{NAME || wrolpiIcon}</i>;
const topNavText = wrolModeEnabled ? <>{name}&nbsp; <Icon name='lock'/></> : name;
const homeLink = <NavLink className='wrolpi-navbar-link' to='/' style={{paddingTop: 0, paddingBottom: 0}}>
{topNavText}
</NavLink>;
// Red/Yellow colors will blend in to some navbar colors, swap the colors for high contrast.
const conflictingColors = ['red', 'orange', 'yellow', 'olive', 'pink'];
const [lowWarningColor, highWarningColor] = conflictingColors.includes(navColor)
? [null, 'black']
: ['yellow', 'red'];
// Generic system load, the least important warning.
const {minute_1, mediumLoad, highLoad} = useLoad();
let systemLoadIcon;
if (mediumLoad || highLoad) {
// System load is high, display a warning icon.
const color = highLoad ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status'>
<Icon name='tachometer alternate' size='large' style={{color: colorVar(color)}}
label='System load warning'/>
</Link>;
systemLoadIcon = <Tooltip label={`Load: ${minute_1}`}>{icon}</Tooltip>
}
// RAM consumption.
const {percent: memoryPercent} = useMemoryStats();
let memoryIcon;
if (memoryPercent > 80) {
const color = memoryPercent > 90 ? highWarningColor : lowWarningColor;
{/*
The color goes on the Icon, as it does for every other indicator. It used to
be passed to `Link`, which ignores it, so the memory warning never actually
changed color -- 81% and 95% looked identical.
*/}
const icon = <Link to='/admin/status'>
<Icon name='microchip' size='large' label='Memory usage warning'
style={{color: colorVar(color)}}/>
</Link>;
memoryIcon = <Tooltip label={`System Memory: ${memoryPercent.toFixed()}%`}>{icon}</Tooltip>
}
// Any disk is busy and processes are waiting.
const {percentIOWait} = useIOStats();
let diskWaitIcon;
if (percentIOWait >= 50) {
// Processes are waiting on disk, display a warning icon.
const color = percentIOWait > 75 ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status'>
<Icon name='disk' size='large' style={{color: colorVar(color)}} label='Disk I/O wait warning'/>
</Link>;
diskWaitIcon = <Tooltip label={`Processes waiting on disk: ${percentIOWait.toFixed()}%`}>{icon}</Tooltip>
}
// CPU temperature.
const {temperature, highTemperature, criticalTemperature} = useCPUTemperature();
let temperatureIcon;
if (temperature && temperature >= highTemperature) {
// CPU temperature is high, display a warning icon.
const color = temperature >= criticalTemperature ? highWarningColor : lowWarningColor;
const temperatureComponent = temperature >= criticalTemperature ? IconTemperaturePlus : IconTemperature;
const icon = <Icon data-testid='cpuTemperatureIcon' component={temperatureComponent} size='large'
style={{color: colorVar(color)}} label='CPU temperature warning'/>
const link = <Link to='/admin/status'>{icon}</Link>;
temperatureIcon = <Tooltip label={`CPU: ${temperature.toFixed()}°C`}>{link}</Tooltip>;
}
// Hard-drive temperature. An overheating drive risks data loss.
const {
device: hotDrive,
temperature: driveTemperature,
highTemperature: driveHighTemperature,
criticalTemperature: driveCriticalTemperature,
} = useDriveTemperature();
let driveTemperatureIcon;
if (driveTemperature && driveTemperature >= driveHighTemperature) {
const color = driveTemperature >= driveCriticalTemperature ? highWarningColor : lowWarningColor;
const icon = <Icon data-testid='driveTemperatureIcon' name='hdd' size='large'
style={{color: colorVar(color)}} label='Drive temperature warning'/>
const link = <Link to='/admin/controller'>{icon}</Link>;
driveTemperatureIcon = <Tooltip label={`${hotDrive}: ${driveTemperature.toFixed()}°C`}>{link}</Tooltip>;
}
// A drive's SMART health is degraded. FAIL is an imminent failure (back
// up now); WARN means unreadable/pending sectors are accumulating. Always
// displayed (like power); too important to hide behind a transient warning
// in the priority chain. Links to the Controller page, which shows the
// SMART detail (the Status page does not).
const {failingDevices, failing: driveFailing, warningDevices, warning: driveWarning} = useDriveHealth();
let driveHealthIcon;
if (driveFailing) {
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large'
style={{color: colorVar(highWarningColor)}} label='Drive health failing'/>;
const link = <Link to='/admin/controller'>{icon}</Link>;
const message = failingDevices.length === 1
? `Drive ${failingDevices[0]} is failing its SMART health check! Back up your data.`
: `Drives failing SMART health check: ${failingDevices.join(', ')}. Back up your data.`;
driveHealthIcon = <Tooltip label={message}>{link}</Tooltip>;
} else if (driveWarning) {
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large'
style={{color: colorVar(lowWarningColor)}} label='Drive health warning'/>;
const link = <Link to='/admin/controller'>{icon}</Link>;
const message = warningDevices.length === 1
? `Drive ${warningDevices[0]} has unreadable or pending sectors — check its SMART health.`
: `Drives with unreadable or pending sectors: ${warningDevices.join(', ')}. Check their SMART health.`;
driveHealthIcon = <Tooltip label={message}>{link}</Tooltip>;
}
// Power issues, this is always displayed if detected.
const {underVoltage, overCurrent} = usePowerStats();
let powerIcon;
if (underVoltage || overCurrent) {
const icon = underVoltage
? <Icon component={IconPlugOff} size='large' style={{color: colorVar(highWarningColor)}}
label='Under-voltage warning'/>
: <Icon name='lightning' size='large' style={{color: colorVar(highWarningColor)}}
label='Over-current warning'/>;
const message = underVoltage
? 'Under-voltage detected! Your power supply is insufficient!'
: 'Over-current detected! Your peripherals are using too much power!';
powerIcon = <Tooltip label={message}><span>{icon}</span></Tooltip>;
}
// Display the temperature icon first because it can cause the system to throttle. The rest are in order of effects
// that will slow down the system and the user should address. Generic load is last because it is probably not an
// issue.
const warningIcon = temperatureIcon || driveTemperatureIcon || diskWaitIcon || memoryIcon || systemLoadIcon;
const {isReorganizing, taskType, collectionId, collectionKind} = useReorganizationStatus();
let processingLink;
if (status && status.flags) {
if (status.flags.file_worker_busy) {
// Smart navigation based on what the file worker is doing
if (isReorganizing && collectionId && collectionKind) {
// Single collection reorganization - navigate to that collection
if (collectionKind === 'channel') {
processingLink = `/videos/channel/${collectionId}/edit`;
} else if (collectionKind === 'domain') {
processingLink = `/archives/domain/${collectionId}/edit`;
} else {
processingLink = '/files';
}
} else if (isReorganizing && taskType === 'batch_reorganize' && collectionKind) {
// Batch reorganization - navigate to settings page for that kind
if (collectionKind === 'channel') {
processingLink = '/videos/settings';
} else if (collectionKind === 'domain') {
processingLink = '/archives/settings';
} else {
processingLink = '/files';
}
} else {
processingLink = '/files';
}
}
}
const processingIcon = processingLink &&
<Link to={processingLink}>
<Icon loading name='circle notch' size='large' label='File worker busy'/>
</Link>;
let apiDownIcon;
if (window.apiDown) {
apiDownIcon = <Tooltip label='API is not responding'>
<span><Icon name='plug' style={{color: colorVar(highWarningColor)}} label='API is not responding'/></span>
</Tooltip>
}
// Upgrade available notification - only show on native (non-Docker) installs and when WROL Mode is disabled
let upgradeIcon;
if (status?.update_available && !status?.dockerized && !wrolModeEnabled) {
const commitsBehind = status.commits_behind || 0;
const branch = status.git_branch || 'unknown';
const icon = <Link to='/admin/settings#upgrade'>
<Icon name='arrow alternate circle up' size='large' style={{color: 'var(--green)'}}
label='Upgrade available'/>
</Link>;
upgradeIcon = <Tooltip label={`Upgrade available: ${commitsBehind} commit(s) behind on ${branch}`}>
{icon}
</Tooltip>;
}
const icons = <React.Fragment>
<NavIconWrapper>{apiDownIcon}</NavIconWrapper>
<NavIconWrapper>{processingIcon}</NavIconWrapper>
<NavIconWrapper>{upgradeIcon}</NavIconWrapper>
<NavIconWrapper>{driveHealthIcon}</NavIconWrapper>
<NavIconWrapper>{powerIcon}</NavIconWrapper>
<NavIconWrapper>{warningIcon}</NavIconWrapper>
<NavIconWrapper><ShareButton/></NavIconWrapper>
<NavIconWrapper><HotspotStatusIcon/></NavIconWrapper>
<NavIconWrapper><DarkModeToggle/></NavIconWrapper>
</React.Fragment>;
return <>
<Media between={['mobile', 'tablet']}>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
{homeLink}
<div className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
<MobileMenu links={[...allLinks, ...rightLinks]}/>
</div>
</nav>
</Media>
<Media greaterThanOrEqual='tablet'>
<DesktopNav navColors={navColors} homeLink={homeLink} icons={icons}/>
</Media>
</>
}
export function DesktopNav({navColors, homeLink, icons}) {
const containerRef = React.useRef(null);
const homeRef = React.useRef(null);
const rightMenuRef = React.useRef(null);
const moreRef = React.useRef(null);
const {visibleLinks, overflowLinks, itemRefCallback, isReady} = useOverflowNav({
links: allLinks,
containerRef,
homeRef,
rightMenuRef,
moreRef,
});
return (
<div ref={containerRef}>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
<span ref={homeRef} style={{display: 'contents'}}>{homeLink}</span>
{(isReady ? visibleLinks : allLinks).map((link, idx) => (
<span
key={link.key}
ref={itemRefCallback(idx)}
style={isReady ? {display: 'contents'} : {visibility: 'hidden', flexShrink: 0}}
>
<MenuLink link={link}/>
</span>
))}
{!isReady && <span ref={moreRef} style={{visibility: 'hidden', flexShrink: 0}}>
<NavDropdownTrigger text='More'/>
</span>}
{isReady && overflowLinks.length > 0 &&
<DropdownLinks link={{text: 'More', key: 'more', links: overflowLinks}}/>}
<div ref={rightMenuRef} className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
{rightLinks.map(i => <MenuLink link={i} key={i.key}/>)}
</div>
</nav>
</div>
);
}