dorfteich/apps/web/src/layout/TopBar.tsx
Claude Fable 5 b799ad180b
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
#182: top-bar theme toggle — cycle light/dark/system without a menu
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
2026-07-29 08:43:42 +02:00

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>
);
}