mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
271 lines
9.8 KiB
TypeScript
271 lines
9.8 KiB
TypeScript
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
|
import { useConfigNavSections } from '@app/components/shared/config/configNavSections';
|
|
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
|
import '@app/components/shared/AppConfigModal.css';
|
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
|
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
|
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
|
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
|
|
import { SettingsSearchBar } from '@app/components/shared/config/SettingsSearchBar';
|
|
|
|
interface AppConfigModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
|
const [active, setActive] = useState<NavKey>('general');
|
|
const isMobile = useIsMobile();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { config } = useAppConfig();
|
|
const licenseAlert = useLicenseAlert();
|
|
const { confirmIfDirty } = useUnsavedChanges();
|
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
// Extract section from URL path (e.g., /settings/people -> people)
|
|
const getSectionFromPath = (pathname: string): NavKey | null => {
|
|
const match = pathname.match(/\/settings\/([^/]+)/);
|
|
if (match && match[1]) {
|
|
const section = match[1] as NavKey;
|
|
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Sync active state with URL path
|
|
useEffect(() => {
|
|
const section = getSectionFromPath(location.pathname);
|
|
if (opened && section) {
|
|
setActive(section);
|
|
} else if (opened && location.pathname.startsWith('/settings') && !section) {
|
|
// If at /settings without a section, redirect to general
|
|
navigate('/settings/general', { replace: true });
|
|
}
|
|
}, [location.pathname, opened, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (opened) {
|
|
// Keep search closed initially by moving autofocus away from the searchable Select input.
|
|
closeButtonRef.current?.focus();
|
|
}
|
|
}, [opened]);
|
|
|
|
// Handle custom events for backwards compatibility
|
|
useEffect(() => {
|
|
const handler = (ev: Event) => {
|
|
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
|
if (detail?.key) {
|
|
navigate(`/settings/${detail.key}`);
|
|
}
|
|
};
|
|
window.addEventListener('appConfig:navigate', handler as EventListener);
|
|
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
|
}, [navigate]);
|
|
|
|
const colors = useMemo(() => ({
|
|
navBg: 'var(--modal-nav-bg)',
|
|
sectionTitle: 'var(--modal-nav-section-title)',
|
|
navItem: 'var(--modal-nav-item)',
|
|
navItemActive: 'var(--modal-nav-item-active)',
|
|
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
|
contentBg: 'var(--modal-content-bg)',
|
|
headerBorder: 'var(--modal-header-border)',
|
|
}), []);
|
|
|
|
// Get isAdmin and runningEE from app config
|
|
const isAdmin = config?.isAdmin ?? false;
|
|
const runningEE = config?.runningEE ?? false;
|
|
const loginEnabled = config?.enableLogin ?? false;
|
|
|
|
// Left navigation structure and icons
|
|
const configNavSections = useConfigNavSections(
|
|
isAdmin,
|
|
runningEE,
|
|
loginEnabled
|
|
);
|
|
|
|
const activeLabel = useMemo(() => {
|
|
for (const section of configNavSections) {
|
|
const found = section.items.find(i => i.key === active);
|
|
if (found) return found.label;
|
|
}
|
|
return '';
|
|
}, [configNavSections, active]);
|
|
|
|
const activeComponent = useMemo(() => {
|
|
for (const section of configNavSections) {
|
|
const found = section.items.find(i => i.key === active);
|
|
if (found) return found.component;
|
|
}
|
|
return null;
|
|
}, [configNavSections, active]);
|
|
|
|
const handleClose = useCallback(async () => {
|
|
const canProceed = await confirmIfDirty();
|
|
if (!canProceed) return;
|
|
|
|
// Navigate back to home when closing modal
|
|
navigate('/', { replace: true });
|
|
onClose();
|
|
}, [confirmIfDirty, navigate, onClose]);
|
|
|
|
const handleNavigation = useCallback(async (key: NavKey) => {
|
|
const canProceed = await confirmIfDirty();
|
|
if (!canProceed) return;
|
|
|
|
setActive(key);
|
|
navigate(`/settings/${key}`);
|
|
}, [confirmIfDirty, navigate]);
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={handleClose}
|
|
title={null}
|
|
size={isMobile ? "100%" : 1100}
|
|
centered
|
|
radius="lg"
|
|
withCloseButton={false}
|
|
zIndex={Z_INDEX_CONFIG_MODAL}
|
|
overlayProps={{ opacity: 0.35, blur: 2 }}
|
|
padding={0}
|
|
fullScreen={isMobile}
|
|
>
|
|
<div className="modal-container">
|
|
{/* Left navigation */}
|
|
<div
|
|
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
|
style={{
|
|
background: colors.navBg,
|
|
borderRight: `1px solid ${colors.headerBorder}`,
|
|
}}
|
|
>
|
|
<div className="modal-nav-scroll">
|
|
{configNavSections.map(section => (
|
|
<div key={section.title} className="modal-nav-section">
|
|
{!isMobile && (
|
|
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
|
{section.title}
|
|
</Text>
|
|
)}
|
|
<div className="modal-nav-section-items">
|
|
{section.items.map(item => {
|
|
const isActive = active === item.key;
|
|
const isDisabled = item.disabled ?? false;
|
|
const color = isActive ? colors.navItemActive : colors.navItem;
|
|
const iconSize = isMobile ? 28 : 18;
|
|
const showPlanWarning =
|
|
item.key === 'adminPlan' &&
|
|
licenseAlert.active &&
|
|
licenseAlert.audience === 'admin';
|
|
|
|
const navItemContent = (
|
|
<div
|
|
key={item.key}
|
|
onClick={() => {
|
|
if (!isDisabled) {
|
|
handleNavigation(item.key);
|
|
}
|
|
}}
|
|
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
|
style={{
|
|
background: isActive ? colors.navItemActiveBg : 'transparent',
|
|
opacity: isDisabled ? 0.6 : 1,
|
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
}}
|
|
data-tour={`admin-${item.key}-nav`}
|
|
>
|
|
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
|
{!isMobile && (
|
|
<Group gap={4} align="center" wrap="nowrap">
|
|
<Text size="sm" fw={500} style={{ color }}>
|
|
{item.label}
|
|
</Text>
|
|
{showPlanWarning && (
|
|
<LocalIcon
|
|
icon="warning-rounded"
|
|
width={14}
|
|
height={14}
|
|
style={{ color: 'var(--mantine-color-orange-7)' }}
|
|
/>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return isDisabled && item.disabledTooltip ? (
|
|
<Tooltip
|
|
key={item.key}
|
|
label={item.disabledTooltip}
|
|
position="right"
|
|
withArrow
|
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
|
>
|
|
{navItemContent}
|
|
</Tooltip>
|
|
) : (
|
|
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right content */}
|
|
<div className="modal-content" data-tour="settings-content-area">
|
|
<div className="modal-content-scroll">
|
|
{/* Sticky header with section title and small close button */}
|
|
<div
|
|
className="modal-header"
|
|
style={{
|
|
background: colors.contentBg,
|
|
borderBottom: `1px solid ${colors.headerBorder}`,
|
|
}}
|
|
>
|
|
<Text fw={700} size="lg">{activeLabel}</Text>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<SettingsSearchBar
|
|
configNavSections={configNavSections}
|
|
onNavigate={handleNavigation}
|
|
isMobile={isMobile}
|
|
/>
|
|
<ActionIcon
|
|
ref={closeButtonRef}
|
|
variant="subtle"
|
|
onClick={handleClose}
|
|
aria-label="Close"
|
|
data-autofocus
|
|
>
|
|
<LocalIcon icon="close-rounded" width={18} height={18} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</div>
|
|
<div className="modal-body">
|
|
{activeComponent}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
// Wrapper component that provides the UnsavedChangesContext
|
|
const AppConfigModal: React.FC<AppConfigModalProps> = (props) => {
|
|
return (
|
|
<UnsavedChangesProvider>
|
|
<AppConfigModalInner {...props} />
|
|
</UnsavedChangesProvider>
|
|
);
|
|
};
|
|
|
|
export default AppConfigModal;
|