import React from 'react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { getSettingsSaveState, subscribeToSettingsSaveState } from '@/lib/persistence'; import { cn } from '@/lib/utils'; import { SETTINGS_DESCRIPTION_CLASS, SETTINGS_PAGE_TITLE_CLASS, } from '@/components/sections/shared/SettingsSection'; interface SettingsPageLayoutProps { /** Page content */ children: React.ReactNode; /** Optional page title shown above settings content. */ title?: React.ReactNode; /** Optional content rendered before a string/number page title. */ titleLeading?: React.ReactNode; /** Optional content rendered after a string/number page title. */ titleAccessory?: React.ReactNode; /** Optional supporting description under the page title. */ description?: React.ReactNode; /** Optional content rendered at the end of the header row (before save status). */ headerEnd?: React.ReactNode; /** Show persistence feedback for instant-save settings. */ showSaveStatus?: boolean; /** Additional className for the content container */ className?: string; /** Additional className for the outer ScrollableOverlay */ outerClassName?: string; } /** * Standard layout wrapper for settings page content. * UI Kit: max-width 840px, padding 32px vertical / 48px horizontal. */ export const SettingsPageLayout: React.FC = ({ children, className, outerClassName, title, titleLeading, titleAccessory, description, headerEnd, showSaveStatus = false, }) => { const hasHeader = title != null || description != null || headerEnd != null || showSaveStatus; const isPlainTitle = typeof title === 'string' || typeof title === 'number'; const hasTitleChrome = titleLeading != null || titleAccessory != null; return (
section:first-of-type]:border-t-0 [&>section:first-of-type]:pt-0', className )} > {hasHeader && ( // Wraps rather than squeezes. The action cluster never shrinks, so on // a narrow pane it used to starve the title until the name was a // single letter and an ellipsis; giving the title block a basis lets // the actions drop to their own line instead.
{title != null ? ( isPlainTitle ? ( hasTitleChrome ? (
{titleLeading}

{title}

{/* A status badge carries a fixed word; compressing it wraps the text inside its own pill. */} {titleAccessory}
) : (

{title}

) ) : ( title ) ) : null} {description != null ? ( typeof description === 'string' || typeof description === 'number' ? (

{description}

) : ( description ) ) : null}
{headerEnd} {showSaveStatus && }
)} {children}
); }; // Only saves slower than this surface a "Saving…" spinner — local writes // finish instantly and stay silent; remote/mobile connections get feedback. const SAVE_SPINNER_DELAY_MS = 500; const SettingsSaveStatus: React.FC = () => { const { t } = useI18n(); const status = React.useSyncExternalStore( subscribeToSettingsSaveState, getSettingsSaveState, getSettingsSaveState, ); const [showSaving, setShowSaving] = React.useState(false); React.useEffect(() => { if (status !== 'saving') { setShowSaving(false); return; } const timer = setTimeout(() => setShowSaving(true), SAVE_SPINNER_DELAY_MS); return () => clearTimeout(timer); }, [status]); if (status === 'error') { return (
{t('settings.common.status.saveFailed')}
); } if (status !== 'saving' || !showSaving) { return null; } return (
{t('settings.common.actions.saving')}
); };