Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx
T
pastilhas 926034750f keyed the dock, header and shell chrome (555 -> 487)
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.

Three things worth knowing:

CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.

Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.

The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.

ThemeSwitcher used t as its theme loop variable; renamed to entry.
2026-08-23 04:40:39 +00:00

169 lines
7.3 KiB
TypeScript

import { useState } from 'react';
import { Link, NavLink } from 'react-router';
import { Menu } from 'lucide-react';
import { EditableTitle } from '@/components/EditableTitle';
// import { Menu, Terminal } from 'lucide-react'; // Terminal used by the commented-out web inspector button
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import type { DockItem } from '../Dock';
import { useIsTouch } from '../useIsTouch';
import { UserMenu } from './UserMenu';
import { JobsIndicator } from './JobsIndicator';
import { ThemeSwitcher } from './ThemeSwitcher';
import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
import { useTranslation } from '@/lib/i18n';
// import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
// refresh and navigation and dies with the tab — see usePageTitle. `allowEmpty` is what makes clearing
// the field hand the tab back to the route's own name.
function EditablePageTitle() {
const [title, rename] = usePageTitle();
const { t } = useTranslation();
return (
<EditableTitle
value={title}
onCommit={rename}
allowEmpty
ariaLabel={t('header.tabName')}
hint={t('header.tabNameHint')}
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
/>
);
}
type HeaderProps = {
dockItems?: DockItem[];
/**
* Stand down for a panel that has taken the whole window. `display: none` rather than an unmount: the
* header holds a half-typed tab rename and its own popovers, and a panel going fullscreen is not a
* reason to throw those away. It is `fixed`, so hiding it costs no layout shift either.
*/
hidden?: boolean;
};
export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
const { user } = useAuth();
const { t } = useTranslation();
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src');
return (
<header className={`fixed z-10 w-full ${hidden ? 'hidden' : ''}`}>
<div
className="relative shrink-0 border-b shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
// Themed. These were literal rgba values, so the header stayed Officer-white under every theme —
// the most visible thing on screen and the least reachable.
style={{
backgroundColor: 'var(--header-bg)',
borderColor: 'var(--header-border)',
backdropFilter: 'blur(var(--header-blur))',
}}
>
{/* Centered, editable page title */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<EditablePageTitle />
</div>
<div className="flex items-center gap-2">
{isTouch && dockItems && (
<button onClick={() => setOpen(true)} className="p-1.5 rounded-md hover:bg-white/20 transition-colors">
<Menu className="h-5 w-5 text-white" />
</button>
)}
<Link to="/">
{/*
A background-image, not an `<img src>`: CSS cannot set a `src`, and a white-labelled build
must be able to replace the mark from its theme rather than by editing this file. `role`
and `aria-label` keep it announced now that it is not an image element.
*/}
<div
role="img"
aria-label={t('header.logoAlt')}
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
letterforms that were adjusted by hand. See landing/assets/brand/WORDMARK.md. */
style={{ backgroundImage: logo ? `url(${logo})` : 'none' }}
/>
</Link>
</div>
<div className="flex items-center gap-2">
{/* Web inspector (eruda dev console) — hidden
<button
onClick={() => (window as any).eruda?.show()}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110"
aria-label="Dev console"
>
<Terminal size={16} />
</button>
*/}
{/* Bug Report — hidden */}
{/* <BugReportButton /> */}
<ThemeSwitcher />
{/* Owner only, and tidiness rather than access control — /jobs stays reachable and the rescan
endpoint is gated server-side, because a hidden button is not a permission. */}
{user?.role === 'Super Admin' && (
<>
<JobsIndicator />
<RescanButton />
</>
)}
<UserMenu />
</div>
</div>
{isTouch && dockItems && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>{t('header.navigation')}</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 mt-4 px-2">
{dockItems.map((item) => (
// `end` only for Home — see the note in Dock.tsx. Everything else wants the ancestor
// match so a detail route keeps its entry highlighted.
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${isActive ? 'bg-white/20' : 'hover:bg-white/10'}`
}
>
{({ isActive }) => (
<>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: item.color,
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
) : (
item.icon && <item.icon className="h-4 w-4 text-white" />
)}
</div>
<span className={`text-sm ${isActive ? 'text-white font-medium' : 'text-white/80'}`}>
{item.label}
</span>
</>
)}
</NavLink>
))}
</nav>
</SheetContent>
</Sheet>
)}
</header>
);
}