Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m40s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m30s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
An IconButton between the notifications bell and the user menu cycles the theme mode in radio order (sun/moon/monitor mirror the CURRENT choice). New useThemeMode() hook is the single write path (persist + apply + same-document event), so the settings radios and the toggle stay in sync; AppearanceSection now uses it too. Also rendered for signed-out visitors — the mode is a device-local preference. i18n de+en; unit tests for cycle/setter, theme.spec covers cycling, radio sync, persistence, and the signed-out top bar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
176 lines
6.2 KiB
TypeScript
176 lines
6.2 KiB
TypeScript
import type { PondView } from '@dorfteich/shared';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Menu, Monitor, Moon, Search, Settings, Sun } from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
|
|
import { useAuth } from '../auth/auth-context';
|
|
import { IconButton } from '../components/IconButton';
|
|
import { apiGet } from '../lib/api';
|
|
import { isTypingTarget } from '../lib/keyboard';
|
|
import { useDismissable } from '../lib/use-dismissable';
|
|
import { SearchPalette } from '../search/SearchPalette';
|
|
import { NotificationsBell } from '../notifications/NotificationsBell';
|
|
import { nextThemeMode, useThemeMode, type ThemeMode } from '../theme/theme';
|
|
import { usePageActionsSlot } from './page-actions';
|
|
import { PondSwitcher } from './PondSwitcher';
|
|
import { useCurrentPondRoute } from './use-pond-route';
|
|
|
|
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
|
|
|
|
/** Icon per CHOSEN mode (not per effective theme): the monitor tells the
|
|
* user "following the OS" apart from an explicit light/dark pick. */
|
|
const THEME_MODE_ICONS: Record<ThemeMode, typeof Sun> = {
|
|
light: Sun,
|
|
dark: Moon,
|
|
system: Monitor,
|
|
};
|
|
|
|
/** Three-way theme cycle (issue #182) — sits between the bell and the user
|
|
* menu; also rendered for signed-out visitors (device-local preference). */
|
|
function ThemeToggle(): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const [mode, setMode] = useThemeMode();
|
|
const Icon = THEME_MODE_ICONS[mode];
|
|
return (
|
|
<IconButton
|
|
className="topbar__theme"
|
|
label={t('settings:appearance.cycle', { mode: t(`settings:appearance.${mode}`) })}
|
|
onClick={() => setMode(nextThemeMode(mode))}
|
|
>
|
|
<Icon aria-hidden />
|
|
</IconButton>
|
|
);
|
|
}
|
|
|
|
interface TopBarProps {
|
|
sidebarCollapsed: boolean;
|
|
onToggleSidebar: () => void;
|
|
}
|
|
|
|
export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const { user, logout } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
|
useDismissable(userMenuRef, menuOpen, () => setMenuOpen(false));
|
|
const { setElement: setPageActionsElement, setPresenceElement } = usePageActionsSlot();
|
|
// Pond-settings shortcut next to the pond name (M10 follow-up): owners get
|
|
// a gear icon while a pond route is active. Shares the sidebar's query key.
|
|
const { pondSlug } = useCurrentPondRoute();
|
|
const pond = useQuery({
|
|
queryKey: ['pond', pondSlug],
|
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
|
enabled: Boolean(user && pondSlug),
|
|
});
|
|
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
|
|
|
|
async function handleLogout(): Promise<void> {
|
|
setMenuOpen(false);
|
|
await logout();
|
|
navigate('/login');
|
|
}
|
|
|
|
// Global "/" shortcut opens search (unless typing in a field) — issue #50.
|
|
useEffect(() => {
|
|
if (!user) return undefined;
|
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
if (
|
|
event.key === '/' &&
|
|
!isTypingTarget(event.target) &&
|
|
!searchOpen &&
|
|
!singleKeyShortcutsDisabled()
|
|
) {
|
|
event.preventDefault();
|
|
setSearchOpen(true);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
}, [user, searchOpen]);
|
|
|
|
return (
|
|
<header className="topbar">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={onToggleSidebar}
|
|
aria-expanded={!sidebarCollapsed}
|
|
aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
|
|
>
|
|
<Menu aria-hidden />
|
|
</button>
|
|
<Link to="/" className="topbar__brand">
|
|
Dorfteich
|
|
</Link>
|
|
{user && <PondSwitcher />}
|
|
{isPondOwner && pondSlug && (
|
|
<Link
|
|
to={`/p/${pondSlug}/settings`}
|
|
className="icon-button topbar__pond-settings"
|
|
aria-label={t('labels:link')}
|
|
title={t('labels:link')}
|
|
>
|
|
<Settings aria-hidden />
|
|
</Link>
|
|
)}
|
|
<span className="topbar__spacer" />
|
|
{/* Page-scoped slots, rendered only for signed-in users: live presence
|
|
(#102) and the page's icon actions (#101) portal in while a page
|
|
route is active. */}
|
|
{user && <div className="topbar__presence" ref={setPresenceElement} />}
|
|
{user && <div className="topbar__page-actions" ref={setPageActionsElement} />}
|
|
{user && (
|
|
<button
|
|
type="button"
|
|
className="topbar__search"
|
|
onClick={() => setSearchOpen(true)}
|
|
aria-label={t('search:open')}
|
|
>
|
|
<Search aria-hidden />
|
|
<span className="topbar__search-text">{t('search:open')}</span>
|
|
</button>
|
|
)}
|
|
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
|
|
{user && <NotificationsBell />}
|
|
<ThemeToggle />
|
|
{user ? (
|
|
<div className="user-menu" ref={userMenuRef}>
|
|
<button
|
|
type="button"
|
|
className="user-menu__trigger"
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
onClick={() => setMenuOpen(!menuOpen)}
|
|
>
|
|
{user.displayName}
|
|
</button>
|
|
{menuOpen && (
|
|
<div className="user-menu__list" role="menu">
|
|
<Link role="menuitem" to="/settings" onClick={() => setMenuOpen(false)}>
|
|
{t('auth:menu.settings')}
|
|
</Link>
|
|
{user.isSiteAdmin && (
|
|
<Link role="menuitem" to="/admin" onClick={() => setMenuOpen(false)}>
|
|
{t('auth:menu.admin')}
|
|
</Link>
|
|
)}
|
|
<button type="button" role="menuitem" onClick={() => void handleLogout()}>
|
|
{t('auth:menu.logout')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<nav className="topbar__nav">
|
|
<Link to="/login">{t('auth:menu.login')}</Link>
|
|
<Link to="/signup">{t('auth:menu.signup')}</Link>
|
|
</nav>
|
|
)}
|
|
</header>
|
|
);
|
|
}
|