✨ refactor: system settings UI for consistent, compact layouts
Redesign the system settings interface to align with the rest of the console experience by using fixed header actions, removing redundant subtitles, respecting global content width, and standardizing responsive form layouts. Introduce reusable settings layout primitives for forms, switch rows, grouped controls, nested control sections, title status indicators, and page action portals. Replace duplicated card-style switch markup with explicit compact components, improve nested switch readability, and reduce visual noise across authentication, billing, content, integrations, maintenance, models, and request-limit settings. Also complete missing i18n translations, remove obsolete subtitle translation keys, refine i18n sync reporting, fix sidebar truncation for long labels, and verify the frontend with type checking and lint diagnostics.
This commit is contained in:
+8
-12
@@ -16,9 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Info } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { SettingsPageTitleStatusPortal } from './settings-page-context'
|
||||
|
||||
type FormDirtyIndicatorProps = {
|
||||
isDirty: boolean
|
||||
@@ -26,7 +25,7 @@ type FormDirtyIndicatorProps = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicator that the form has unsaved changes
|
||||
* Compact page-title status indicator for unsaved form changes.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -41,14 +40,11 @@ export function FormDirtyIndicator({
|
||||
if (!isDirty) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant='default'
|
||||
className='border-orange-500/50 bg-orange-50 dark:bg-orange-950/20'
|
||||
>
|
||||
<Info className='h-4 w-4 text-orange-600 dark:text-orange-500' />
|
||||
<AlertDescription className='text-orange-800 dark:text-orange-400'>
|
||||
{message ?? t('You have unsaved changes')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SettingsPageTitleStatusPortal>
|
||||
<span className='inline-flex h-5 items-center gap-1.5 rounded-full bg-amber-500/10 px-2 text-[11px] font-medium whitespace-nowrap text-amber-700 ring-1 ring-amber-500/20 ring-inset dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/20'>
|
||||
<span className='size-1.5 rounded-full bg-amber-500 dark:bg-amber-300' />
|
||||
{message ? t(message) : t('Unsaved changes')}
|
||||
</span>
|
||||
</SettingsPageTitleStatusPortal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
type SettingsAccordionProps = {
|
||||
value: string
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
@@ -33,18 +33,14 @@ type SettingsAccordionProps = {
|
||||
export function SettingsAccordion({
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: SettingsAccordionProps) {
|
||||
return (
|
||||
<AccordionItem value={value} className={className}>
|
||||
<AccordionItem value={value} className={cn(className)}>
|
||||
<AccordionTrigger className='hover:no-underline'>
|
||||
<div className='flex flex-col gap-1 text-left'>
|
||||
<div className='text-base font-semibold'>{title}</div>
|
||||
{description && (
|
||||
<div className='text-muted-foreground text-sm'>{description}</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='pt-4'>{children}</AccordionContent>
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FormItem } from '@/components/ui/form'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
type SettingsFormGridProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
type SettingsFormGridItemProps = SettingsFormGridProps & {
|
||||
span?: 'default' | 'full'
|
||||
}
|
||||
|
||||
type SettingsSwitchItemProps = ComponentProps<typeof FormItem>
|
||||
type SettingsSwitchRowProps = ComponentProps<'div'>
|
||||
type SettingsControlGroupProps = ComponentProps<'div'>
|
||||
type SettingsControlChildrenProps = ComponentProps<'div'>
|
||||
type SettingsSwitchFieldProps = SettingsSwitchRowProps & {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const settingsSwitchRowClassName =
|
||||
'flex min-w-0 flex-row items-center justify-between gap-4 border-b py-2.5 last:border-b-0'
|
||||
|
||||
export function SettingsFormGrid(props: SettingsFormGridProps) {
|
||||
return (
|
||||
<div
|
||||
data-settings-form-span='full'
|
||||
className={cn(
|
||||
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsFormGridItem(props: SettingsFormGridItemProps) {
|
||||
return (
|
||||
<div
|
||||
data-settings-form-span={props.span === 'full' ? 'full' : undefined}
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
props.span === 'full' && 'lg:col-span-2',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchItem({
|
||||
className,
|
||||
...props
|
||||
}: SettingsSwitchItemProps) {
|
||||
return (
|
||||
<FormItem
|
||||
data-settings-form-span='full'
|
||||
className={cn(settingsSwitchRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchRow({
|
||||
className,
|
||||
...props
|
||||
}: SettingsSwitchRowProps) {
|
||||
return (
|
||||
<div
|
||||
data-settings-form-span='full'
|
||||
className={cn(settingsSwitchRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchField({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: SettingsSwitchFieldProps) {
|
||||
return (
|
||||
<SettingsSwitchRow className={className} {...props}>
|
||||
<SettingsSwitchContent>
|
||||
<Label className='text-sm font-medium'>{label}</Label>
|
||||
{description ? (
|
||||
<p className='text-muted-foreground text-xs'>{description}</p>
|
||||
) : null}
|
||||
</SettingsSwitchContent>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsSwitchRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchContent(props: SettingsFormGridProps) {
|
||||
return (
|
||||
<div className={cn('min-w-0 space-y-0.5', props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsControlGroup({
|
||||
className,
|
||||
...props
|
||||
}: SettingsControlGroupProps) {
|
||||
return (
|
||||
<div
|
||||
data-settings-form-span='full'
|
||||
className={cn(
|
||||
'bg-muted/20 min-w-0 space-y-3 rounded-xl border px-3 py-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsControlChildren({
|
||||
className,
|
||||
...props
|
||||
}: SettingsControlChildrenProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('border-border/70 ml-2 min-w-0 border-l pl-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsForm({ className, ...props }: ComponentProps<'form'>) {
|
||||
return (
|
||||
<form
|
||||
className={cn(
|
||||
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2',
|
||||
'lg:[&>*:not([data-slot=form-item])]:col-span-2',
|
||||
'lg:[&>[data-settings-form-span=full]]:col-span-2',
|
||||
'lg:[&>[data-slot=alert]]:col-span-2',
|
||||
'[&>[data-slot=form-item]]:min-w-0',
|
||||
'lg:[&>[data-slot=form-item]:has(textarea)]:col-span-2',
|
||||
'lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
import { RotateCcw, Save } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type SettingsPageContextValue = {
|
||||
actionsContainer: HTMLDivElement | null
|
||||
titleStatusContainer: HTMLSpanElement | null
|
||||
suppressSectionHeader: boolean
|
||||
}
|
||||
|
||||
const SettingsPageContext = createContext<SettingsPageContextValue>({
|
||||
actionsContainer: null,
|
||||
titleStatusContainer: null,
|
||||
suppressSectionHeader: false,
|
||||
})
|
||||
|
||||
type SettingsPageProviderProps = {
|
||||
actionsContainer: HTMLDivElement | null
|
||||
titleStatusContainer?: HTMLSpanElement | null
|
||||
children: ReactNode
|
||||
suppressSectionHeader?: boolean
|
||||
}
|
||||
|
||||
export function SettingsPageProvider(props: SettingsPageProviderProps) {
|
||||
return (
|
||||
<SettingsPageContext.Provider
|
||||
value={{
|
||||
actionsContainer: props.actionsContainer,
|
||||
titleStatusContainer: props.titleStatusContainer ?? null,
|
||||
suppressSectionHeader: props.suppressSectionHeader ?? true,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</SettingsPageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSuppressSettingsSectionHeader() {
|
||||
return useContext(SettingsPageContext).suppressSectionHeader
|
||||
}
|
||||
|
||||
type SettingsPageTitleStatusPortalProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SettingsPageTitleStatusPortal(
|
||||
props: SettingsPageTitleStatusPortalProps
|
||||
) {
|
||||
const { titleStatusContainer } = useContext(SettingsPageContext)
|
||||
|
||||
if (!titleStatusContainer) return null
|
||||
|
||||
return createPortal(props.children, titleStatusContainer)
|
||||
}
|
||||
|
||||
type SettingsPageActionsPortalProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SettingsPageActionsPortal(
|
||||
props: SettingsPageActionsPortalProps
|
||||
) {
|
||||
const { actionsContainer } = useContext(SettingsPageContext)
|
||||
|
||||
if (!actionsContainer) return null
|
||||
|
||||
return createPortal(
|
||||
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||
{props.children}
|
||||
</div>,
|
||||
actionsContainer
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsPageFormActionsProps = {
|
||||
onSave: () => void
|
||||
onReset?: () => void
|
||||
isSaving?: boolean
|
||||
isSaveDisabled?: boolean
|
||||
isResetDisabled?: boolean
|
||||
saveLabel?: string
|
||||
savingLabel?: string
|
||||
resetLabel?: string
|
||||
resetVariant?: ComponentProps<typeof Button>['variant']
|
||||
saveButtonRef?: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
export function SettingsPageFormActions(props: SettingsPageFormActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const saveLabel = props.isSaving
|
||||
? (props.savingLabel ?? 'Saving...')
|
||||
: (props.saveLabel ?? 'Save Changes')
|
||||
|
||||
return (
|
||||
<SettingsPageActionsPortal>
|
||||
{props.onReset && (
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant={props.resetVariant ?? 'outline'}
|
||||
onClick={props.onReset}
|
||||
disabled={props.isResetDisabled || props.isSaving}
|
||||
>
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
<span>{t(props.resetLabel ?? 'Reset')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ref={props.saveButtonRef}
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={props.onSave}
|
||||
disabled={props.isSaving || props.isSaveDisabled}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
<span>{t(saveLabel)}</span>
|
||||
</Button>
|
||||
</SettingsPageActionsPortal>
|
||||
)
|
||||
}
|
||||
@@ -16,13 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
|
||||
import type { SystemOption } from '../types'
|
||||
import { SettingsPageProvider } from './settings-page-context'
|
||||
|
||||
type SettingsPageProps<
|
||||
TSettings extends Record<string, string | number | boolean | unknown[]>,
|
||||
TSectionId extends string,
|
||||
TExtraArgs extends unknown[] = [],
|
||||
> = {
|
||||
routePath: string
|
||||
defaultSettings: TSettings
|
||||
@@ -30,9 +35,57 @@ type SettingsPageProps<
|
||||
getSectionContent: (
|
||||
sectionId: TSectionId,
|
||||
settings: TSettings,
|
||||
...extraArgs: unknown[]
|
||||
) => React.ReactNode
|
||||
extraArgs?: unknown[]
|
||||
...extraArgs: TExtraArgs
|
||||
) => ReactNode
|
||||
getSectionMeta: (sectionId: TSectionId) => {
|
||||
titleKey: string
|
||||
}
|
||||
extraArgs?: TExtraArgs
|
||||
loadingMessage?: string
|
||||
resolveSettings?: (
|
||||
settings: TSettings,
|
||||
raw: SystemOption[] | undefined
|
||||
) => TSettings
|
||||
}
|
||||
|
||||
type SettingsPageFrameProps = {
|
||||
title: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function SettingsPageFrame(props: SettingsPageFrameProps) {
|
||||
const [actionsContainer, setActionsContainer] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
const [titleStatusContainer, setTitleStatusContainer] =
|
||||
useState<HTMLSpanElement | null>(null)
|
||||
|
||||
return (
|
||||
<SettingsPageProvider
|
||||
actionsContainer={actionsContainer}
|
||||
titleStatusContainer={titleStatusContainer}
|
||||
>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>
|
||||
<span className='inline-flex max-w-full min-w-0 items-center gap-2 align-middle'>
|
||||
<span className='truncate'>{props.title}</span>
|
||||
<span
|
||||
ref={setTitleStatusContainer}
|
||||
className='inline-flex shrink-0'
|
||||
/>
|
||||
</span>
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<div
|
||||
ref={setActionsContainer}
|
||||
className='flex flex-wrap items-center justify-end gap-2'
|
||||
/>
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='flex w-full flex-col gap-4'>{props.children}</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
</SettingsPageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,39 +95,53 @@ type SettingsPageProps<
|
||||
export function SettingsPage<
|
||||
TSettings extends Record<string, string | number | boolean | unknown[]>,
|
||||
TSectionId extends string,
|
||||
TExtraArgs extends unknown[] = [],
|
||||
>({
|
||||
routePath,
|
||||
defaultSettings,
|
||||
defaultSection,
|
||||
getSectionContent,
|
||||
extraArgs = [],
|
||||
}: SettingsPageProps<TSettings, TSectionId>) {
|
||||
getSectionMeta,
|
||||
extraArgs,
|
||||
loadingMessage = 'Loading settings...',
|
||||
resolveSettings,
|
||||
}: SettingsPageProps<TSettings, TSectionId, TExtraArgs>) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params = useParams({ from: routePath as any })
|
||||
const activeSection = (params?.section ?? defaultSection) as TSectionId
|
||||
const sectionMeta = getSectionMeta(activeSection)
|
||||
|
||||
const settings = useMemo(() => {
|
||||
const baseSettings = getOptionValue(
|
||||
data?.data,
|
||||
defaultSettings
|
||||
) as TSettings
|
||||
return resolveSettings
|
||||
? resolveSettings(baseSettings, data?.data)
|
||||
: baseSettings
|
||||
}, [data?.data, defaultSettings, resolveSettings])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-muted-foreground'>{t('Loading settings...')}</div>
|
||||
</div>
|
||||
<SettingsPageFrame title={t(sectionMeta.titleKey)}>
|
||||
<div className='text-muted-foreground flex min-h-40 items-center justify-center text-sm'>
|
||||
{t(loadingMessage)}
|
||||
</div>
|
||||
</SettingsPageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const settings = getOptionValue(data?.data, defaultSettings) as TSettings
|
||||
const activeSection = (params?.section ?? defaultSection) as TSectionId
|
||||
const sectionContent = getSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
...extraArgs
|
||||
...((extraArgs ?? []) as TExtraArgs)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsPageFrame title={t(sectionMeta.titleKey)}>
|
||||
{sectionContent}
|
||||
</SettingsPageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSuppressSettingsSectionHeader } from './settings-page-context'
|
||||
|
||||
type SettingsSectionProps = {
|
||||
title: string
|
||||
titleProps?: React.HTMLAttributes<HTMLHeadingElement>
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
@@ -27,32 +29,23 @@ type SettingsSectionProps = {
|
||||
export function SettingsSection({
|
||||
title,
|
||||
titleProps,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: SettingsSectionProps) {
|
||||
const baseClassName = 'space-y-4'
|
||||
const sectionClassName = className
|
||||
? `${baseClassName} ${className}`
|
||||
: baseClassName
|
||||
const suppressHeader = useSuppressSettingsSectionHeader()
|
||||
|
||||
return (
|
||||
<section className={sectionClassName}>
|
||||
<div className='space-y-1'>
|
||||
<h3
|
||||
{...titleProps}
|
||||
className={
|
||||
titleProps?.className
|
||||
? `text-base font-semibold ${titleProps.className}`
|
||||
: 'text-base font-semibold'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<section className={cn('flex flex-col gap-4', className)}>
|
||||
{!suppressHeader && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h3
|
||||
{...titleProps}
|
||||
className={cn('text-base font-semibold', titleProps?.className)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user