317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
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;
|