feat: Rework sidebar with dedicated components for menu, folders, tags, correspondents, and search, including tenant switching and theme controls.

This commit is contained in:
2025-11-25 15:09:20 +01:00
parent 2c42e63bab
commit d6206786f7
14 changed files with 1192 additions and 896 deletions
@@ -0,0 +1,316 @@
import React, { useCallback, useId } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import {
GithubIcon,
MatrixIcon,
WorldIcon,
CheckIcon,
SettingsIcon,
LogoutIcon,
RestoreIcon,
SunIcon,
MoonIcon,
DesktopIcon,
} from '../../ui/icons';
import { useSidebarContext } from '../SidebarContext';
import { THEME_MODE_LABELS, THEME_MODES } from '../../constants/sidebar';
import type { Identifier } from '../../types/identifiers';
export interface TenantOption {
id?: Identifier | null;
name?: string | null;
}
interface CommunityLink {
label: string;
href: string;
title: string;
Icon: typeof GithubIcon;
}
const COMMUNITY_LINKS: CommunityLink[] = [
{
label: 'GitHub',
href: 'https://github.com/papercrate-dms/papercrate',
title: 'Open Papercrate on GitHub',
Icon: GithubIcon,
},
{
label: 'Matrix',
href: 'https://matrix.to/#/#papercrate:matrix.org',
title: 'Join the Papercrate Matrix room',
Icon: MatrixIcon,
},
{
label: 'Website',
href: 'https://papercrate.org',
title: 'Visit papercrate.org',
Icon: WorldIcon,
},
];
interface SidebarMenuProps {
isOpen: boolean;
menuRef: React.RefObject<HTMLDivElement>;
style: CSSProperties | null;
tenants: TenantOption[];
activeTenantId: Identifier | null;
onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void;
onOpenSettings?: () => void;
onLogout?: () => void;
onClose: () => void;
}
const SidebarMenu: React.FC<SidebarMenuProps> = ({
isOpen,
menuRef,
style,
tenants,
activeTenantId,
onSelectTenant,
onOpenSettings,
onLogout,
onClose,
}) => {
const {
neutralHue,
setNeutralHue,
resetNeutralHue,
neutralChroma,
setNeutralChroma,
resetNeutralChroma,
themeMode,
cycleThemeMode,
themeModes,
} = useSidebarContext();
const neutralHueInputId = useId();
const neutralChromaInputId = useId();
const handleThemeAdjustmentsReset = useCallback(() => {
resetNeutralHue();
resetNeutralChroma();
}, [resetNeutralHue, resetNeutralChroma]);
const handleNeutralHueChange = useCallback(
(value: string) => {
if (value === '') {
resetNeutralHue();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralHue(parsed);
},
[resetNeutralHue, setNeutralHue],
);
const handleNeutralChromaChange = useCallback(
(value: string) => {
if (value === '') {
resetNeutralChroma();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralChroma(parsed);
},
[resetNeutralChroma, setNeutralChroma],
);
const formatSliderValue = useCallback((value: number | string) => {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return String(value);
}
return parsed.toFixed(2);
}, []);
const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES;
const themeModeIndex = themeModeList.indexOf(themeMode as string);
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0];
const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length];
const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system;
const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system;
const themeModeIcon = resolvedThemeMode === 'dark'
? <MoonIcon size={16} />
: resolvedThemeMode === 'light'
? <SunIcon size={16} />
: <DesktopIcon size={16} />;
const handleThemeModeToggle = useCallback(() => {
cycleThemeMode();
}, [cycleThemeMode]);
const handleTenantSelect = useCallback(
(tenant: TenantOption | null) => {
const targetId = tenant?.id || null;
if (!targetId) {
return;
}
onClose();
onSelectTenant?.(tenant);
},
[onClose, onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
onClose();
onLogout?.();
}, [onClose, onLogout]);
const handleSettingsFromMenu = useCallback(() => {
onClose();
onOpenSettings?.();
}, [onClose, onOpenSettings]);
const themeMenuSection =
neutralHue != null
? (
<div className="menu__section">
<div className="menu__heading menu__heading--with-actions">
<span>Theme</span>
<div className="menu__heading-actions">
<button
type="button"
className="icon-button"
onClick={handleThemeModeToggle}
aria-label={`Switch theme (next: ${nextThemeLabel})`}
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
>
{themeModeIcon}
</button>
<button
type="button"
className="icon-button"
onClick={handleThemeAdjustmentsReset}
aria-label="Reset theme adjustments"
title="Reset theme adjustments"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="menu__slider" htmlFor={neutralHueInputId}>
<span className="menu__slider-label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
onDoubleClick={resetNeutralHue}
/>
<span className="menu__slider-value">{neutralHue}°</span>
</label>
<label className="menu__slider" htmlFor={neutralChromaInputId}>
<span className="menu__slider-label">Chroma</span>
<input
id={neutralChromaInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralChroma}
onChange={(event) => handleNeutralChromaChange(event.target.value)}
onDoubleClick={resetNeutralChroma}
/>
<span className="menu__slider-value">{formatSliderValue(neutralChroma)}</span>
</label>
</div>
)
: null;
const communityMenuFooter = COMMUNITY_LINKS.length
? (
<div className="menu__footer">
{COMMUNITY_LINKS.map(({ href, label, title, Icon }) => (
<a
key={href}
href={href}
target="_blank"
rel="noreferrer noopener"
className="menu__footer-link"
title={title}
>
<Icon size={16} stroke={1.5} />
<span>{label}</span>
</a>
))}
</div>
)
: null;
const showTenantList = tenants.length > 1;
const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' menu--simple' : ''}`;
if (!isOpen || !style) {
return null;
}
return createPortal(
<div
className={menuClassName}
ref={menuRef}
role="menu"
aria-label="Account menu"
style={style}
>
{showTenantList ? (
<div className="menu__section">
<div className="menu__heading">Switch tenant</div>
<div className="menu__wrapper">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name || tenantId || 'Tenant';
return (
<button
key={tenantId ?? tenantLabel}
type="button"
className={`menu__button${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
</div>
</div>
) : null}
<div className="menu__section">
<div className="menu__wrapper">
<button type="button" className="menu__button" onClick={handleSettingsFromMenu}>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__button menu__button--danger"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
{themeMenuSection}
{communityMenuFooter}
</div>,
document.body,
);
};
export default SidebarMenu;