feat: refactor settings components and sidebar layout

This commit is contained in:
Bohdan Triapitsyn
2025-12-28 02:17:09 +02:00
parent c074b9a3f2
commit 0fefc1b301
27 changed files with 1788 additions and 675 deletions
@@ -0,0 +1,44 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
interface SettingsPageLayoutProps {
/** Page content */
children: React.ReactNode;
/** Additional className for the content container */
className?: string;
/** Additional className for the outer ScrollableOverlay */
outerClassName?: string;
}
/**
* Standard layout wrapper for settings page content.
* Provides scrolling and centered max-width container.
*
* @example
* <SettingsPageLayout>
* <SettingsSection title="General">
* <SomeSettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <OtherSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
*/
export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
children,
className,
outerClassName,
}) => {
return (
<ScrollableOverlay
outerClassName={cn('h-full', outerClassName)}
className={cn(
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6',
className
)}
>
{children}
</ScrollableOverlay>
);
};
@@ -0,0 +1,62 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface SettingsSectionProps {
/** Section content */
children: React.ReactNode;
/** Optional section title */
title?: string;
/** Optional section description */
description?: string;
/** If true, adds a top border divider */
divider?: boolean;
/** Additional className */
className?: string;
}
/**
* Standard section wrapper for settings page content.
* Provides consistent spacing and optional divider.
*
* @example
* <SettingsSection title="Appearance" description="Customize the look and feel">
* <ThemeSelector />
* <FontSizeSelector />
* </SettingsSection>
*
* <SettingsSection divider>
* <DangerZoneSettings />
* </SettingsSection>
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({
children,
title,
description,
divider = false,
className,
}) => {
return (
<div
className={cn(
divider && 'border-t border-border/40 pt-6',
className
)}
>
{(title || description) && (
<div className="mb-4 space-y-1">
{title && (
<h3 className="typography-ui-header font-semibold text-foreground">
{title}
</h3>
)}
{description && (
<p className="typography-meta text-muted-foreground">
{description}
</p>
)}
</div>
)}
{children}
</div>
);
};
@@ -0,0 +1,63 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { RiAddLine } from '@remixicon/react';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
interface SettingsSidebarHeaderProps {
/** Total count to display (e.g., "Total 5") */
count: number;
/** Callback when add button is clicked. If undefined, no add button is shown. */
onAdd?: () => void;
/** Custom label prefix (default: "Total") */
label?: string;
/** Aria label for the add button */
addButtonLabel?: string;
}
/**
* Standard header for settings sidebars.
* Displays "Total X" on the left and an optional add button on the right.
*
* @example
* <SettingsSidebarHeader
* count={agents.length}
* onAdd={() => setCreateDialogOpen(true)}
* addButtonLabel="Create new agent"
* />
*/
export const SettingsSidebarHeader: React.FC<SettingsSidebarHeaderProps> = ({
count,
onAdd,
label = 'Total',
addButtonLabel = 'Add new item',
}) => {
const { isMobile } = useDeviceInfo();
return (
<div
className={cn(
'border-b border-border/40 px-3 dark:border-white/10',
isMobile ? 'mt-2 py-3' : 'py-3'
)}
>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">
{label} {count}
</span>
{onAdd && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={onAdd}
aria-label={addButtonLabel}
>
<RiAddLine className="size-4" />
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,134 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiMore2Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
export interface SettingsSidebarItemAction {
/** Label shown in dropdown menu */
label: string;
/** Icon component to show before label */
icon?: React.ComponentType<{ className?: string }>;
/** Callback when action is clicked */
onClick: () => void;
/** If true, uses destructive styling (red text) */
destructive?: boolean;
}
interface SettingsSidebarItemProps {
/** Primary title text */
title: React.ReactNode;
/** Secondary metadata text (shown below title) */
metadata?: React.ReactNode;
/** Whether this item is currently selected */
selected?: boolean;
/** Callback when item is clicked */
onSelect: () => void;
/** Optional icon to show before title */
icon?: React.ReactNode;
/** Actions shown in dropdown menu. If empty/undefined, no dropdown is shown. */
actions?: SettingsSidebarItemAction[];
/** Additional className for the outer container */
className?: string;
}
/**
* Standard list item for settings sidebars.
* Provides consistent styling for title, metadata, selection state, and optional actions dropdown.
*
* @example
* <SettingsSidebarItem
* title={agent.name}
* metadata={agent.description}
* selected={selectedId === agent.id}
* onSelect={() => setSelectedId(agent.id)}
* icon={<RiRobotLine className="h-4 w-4" />}
* actions={[
* { label: 'Duplicate', icon: RiFileCopyLine, onClick: handleDuplicate },
* { label: 'Delete', icon: RiDeleteBinLine, onClick: handleDelete, destructive: true },
* ]}
* />
*/
export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
title,
metadata,
selected = false,
onSelect,
icon,
actions,
className,
}) => {
const hasActions = actions && actions.length > 0;
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
selected
? 'dark:bg-accent/80 bg-primary/12'
: 'hover:dark:bg-accent/40 hover:bg-primary/6',
className
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
{icon}
<span className="typography-ui-label font-normal truncate text-foreground">
{title}
</span>
</div>
{metadata && (
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{metadata}
</div>
)}
</button>
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{actions.map((action, index) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={index}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
className={cn(
action.destructive && 'text-destructive focus:text-destructive'
)}
>
{Icon && <Icon className="h-4 w-4 mr-px" />}
{action.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};
@@ -0,0 +1,65 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
interface SettingsSidebarLayoutProps {
/** Header content (typically SettingsSidebarHeader) */
header?: React.ReactNode;
/** Footer content (e.g., AboutSettings on mobile) */
footer?: React.ReactNode;
/** Main scrollable content */
children: React.ReactNode;
/** Additional className for the outer container */
className?: string;
}
/**
* Standard layout wrapper for settings sidebars.
* Provides consistent background, scrolling, and header/footer slots.
*
* @example
* <SettingsSidebarLayout
* header={<SettingsSidebarHeader count={items.length} onAdd={handleAdd} />}
* >
* {items.map(item => (
* <SettingsSidebarItem key={item.id} ... />
* ))}
* </SettingsSidebarLayout>
*/
export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
header,
footer,
children,
className,
}) => {
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
return (
<div
className={cn(
'flex h-full flex-col',
isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar',
className
)}
>
{header}
<ScrollableOverlay
outerClassName="flex-1 min-h-0"
className="space-y-1 px-3 py-2 overflow-x-hidden"
>
{children}
</ScrollableOverlay>
{footer}
</div>
);
};
@@ -0,0 +1,56 @@
/**
* Shared boilerplate components for settings sections.
*
* These components provide consistent styling and behavior for settings sidebars and pages.
* Use them as building blocks when creating new settings sections.
*
* @example Sidebar usage:
* ```tsx
* import {
* SettingsSidebarLayout,
* SettingsSidebarHeader,
* SettingsSidebarItem,
* } from '@/components/sections/shared';
*
* export const MySidebar = () => (
* <SettingsSidebarLayout
* header={<SettingsSidebarHeader count={items.length} onAdd={handleAdd} />}
* >
* {items.map(item => (
* <SettingsSidebarItem
* key={item.id}
* title={item.name}
* metadata={item.description}
* selected={selectedId === item.id}
* onSelect={() => setSelectedId(item.id)}
* actions={[
* { label: 'Delete', onClick: () => handleDelete(item.id), destructive: true }
* ]}
* />
* ))}
* </SettingsSidebarLayout>
* );
* ```
*
* @example Page usage:
* ```tsx
* import { SettingsPageLayout, SettingsSection } from '@/components/sections/shared';
*
* export const MyPage = () => (
* <SettingsPageLayout>
* <SettingsSection title="General Settings">
* <MySettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <AdvancedSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
* );
* ```
*/
export { SettingsSidebarLayout } from './SettingsSidebarLayout';
export { SettingsSidebarHeader } from './SettingsSidebarHeader';
export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem';
export { SettingsPageLayout } from './SettingsPageLayout';
export { SettingsSection } from './SettingsSection';