style(ui): unify modal dialogs with SidebarDialog component

Replace ModalFrame and related modal patterns in settings and upload dialogs with
the new SidebarDialog component to establish a consistent sidebar-based dialog
layout. Refactor SettingsModal and UploadMenuDialog to use SidebarDialog, updating
header, navigation, and content handling accordingly. Update DocumentList to use
DocumentUploader in compact mode for sidebar upload action. Export SidebarDialog
from the UI module. Minor fixes in web-loader for IP parsing and option naming.

This change standardizes dialog UI structure and improves maintainability.
This commit is contained in:
Richard R 2026-06-10 11:14:33 -06:00
parent 5a44a59ed1
commit d86df06f9b
8 changed files with 402 additions and 364 deletions

View file

@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { fetchAndParseUrl } from '@/lib/server/documents/web-loader'; import { fetchAndParseUrl } from '@/lib/server/documents/web-loader';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';

View file

@ -42,7 +42,7 @@ import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import { import {
SidebarNav, SidebarDialog,
SidebarNavItem, SidebarNavItem,
SegmentedControl, SegmentedControl,
Button, Button,
@ -50,8 +50,6 @@ import {
IconButton, IconButton,
Input, Input,
Textarea, Textarea,
ModalFrame,
ModalTitle,
Select, Select,
} from '@/components/ui'; } from '@/components/ui';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -562,17 +560,15 @@ export function SettingsModal({
return ( return (
<> <>
<ModalFrame <SidebarDialog
open={isOpen} open={isOpen}
onClose={resetToCurrent} onClose={resetToCurrent}
size="xl" size="xl"
panelTestId="settings-modal" panelTestId="settings-modal"
className={isChangelogOpen ? 'z-[90]' : 'z-50'} modalClassName={isChangelogOpen ? 'z-[90]' : 'z-50'}
> headerTitle={
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
<div className="flex items-baseline gap-4"> <div className="flex items-baseline gap-4">
<ModalTitle>Settings</ModalTitle> <span>Settings</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -582,7 +578,8 @@ export function SettingsModal({
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'} {displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
</Button> </Button>
</div> </div>
<div className="flex items-center"> }
headerRight={
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -591,61 +588,27 @@ export function SettingsModal({
> >
Privacy Privacy
</Button> </Button>
</div> }
</div> showCloseButton={false}
sections={visibleSections}
{isChangelogOpen ? ( activeSectionId={activeSection}
onSectionChange={setActiveSection}
className="h-[490px]"
contentClassName={
activeSection === 'admin'
? 'bg-[radial-gradient(circle_at_top_right,color-mix(in_srgb,var(--accent),transparent_92%),transparent_35%)]'
: ''
}
customContent={
isChangelogOpen ? (
<SettingsChangelogPanel <SettingsChangelogPanel
appVersion={runtimeConfig.appVersion} appVersion={runtimeConfig.appVersion}
manifestUrl={runtimeConfig.changelogFeedUrl} manifestUrl={runtimeConfig.changelogFeedUrl}
onClose={() => setIsChangelogOpen(false)} onClose={() => setIsChangelogOpen(false)}
/> />
) : ( ) : undefined
<> }
{/* Mobile nav */} >
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
{visibleSections.map((section) => {
const Icon = section.icon;
return (
<SidebarNavItem
compact
key={section.id}
onClick={() => setActiveSection(section.id)}
active={activeSection === section.id}
icon={<Icon className="w-3.5 h-3.5" />}
label={section.label}
/>
);
})}
</SidebarNav>
<div className="flex flex-row h-[490px]">
{/* Desktop: vertical sidebar */}
<nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
<SidebarNav>
{visibleSections.map((section) => {
const Icon = section.icon;
const active = activeSection === section.id;
return (
<SidebarNavItem
key={section.id}
onClick={() => setActiveSection(section.id)}
active={active}
icon={<Icon className="w-4 h-4" />}
label={section.label}
className="whitespace-nowrap"
/>
);
})}
</SidebarNav>
</nav>
{/* Content */}
<div className={`flex-1 min-w-0 p-3 overflow-y-auto ${
activeSection === 'admin'
? 'bg-[radial-gradient(circle_at_top_right,color-mix(in_srgb,var(--accent),transparent_92%),transparent_35%)]'
: ''
}`}>
{/* API Section */} {/* API Section */}
{activeSection === 'api' && ( {activeSection === 'api' && (
<div className="space-y-4"> <div className="space-y-4">
@ -1174,11 +1137,7 @@ export function SettingsModal({
</div> </div>
</div> </div>
)} )}
</div> </SidebarDialog>
</div>
</>
)}
</ModalFrame>
<ConfirmDialog <ConfirmDialog
isOpen={showDeleteAccountConfirm} isOpen={showDeleteAccountConfirm}

View file

@ -22,8 +22,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader';
import { IconButton, SidebarNavItem } from '@/components/ui'; import { IconButton } from '@/components/ui';
import { UploadIcon } from '@/components/icons/Icons';
import { UploadMenuDialog } from '@/components/documents/UploadMenuDialog'; import { UploadMenuDialog } from '@/components/documents/UploadMenuDialog';
import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentDndProvider } from './dnd/DocumentDndProvider';
import { import {
@ -623,11 +622,10 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
width={sidebarWidth} width={sidebarWidth}
onWidthChange={setSidebarWidth} onWidthChange={setSidebarWidth}
topSlot={( topSlot={(
<SidebarNavItem <DocumentUploader
compact variant="compact"
onUploadBatchChange={handleUploadBatchChange}
onClick={() => setIsUploadDialogOpen(true)} onClick={() => setIsUploadDialogOpen(true)}
icon={<UploadIcon className="w-3.5 h-3.5" />}
label="Upload documents"
/> />
)} )}
bottomSlot={( bottomSlot={(

View file

@ -12,6 +12,7 @@ interface DocumentUploaderProps {
variant?: 'default' | 'compact' | 'overlay'; variant?: 'default' | 'compact' | 'overlay';
children?: ReactNode; children?: ReactNode;
onUploadBatchChange?: (state: UploadBatchState) => void; onUploadBatchChange?: (state: UploadBatchState) => void;
onClick?: () => void;
} }
export interface UploadBatchState { export interface UploadBatchState {
@ -28,6 +29,7 @@ export function DocumentUploader({
variant = 'default', variant = 'default',
children, children,
onUploadBatchChange, onUploadBatchChange,
onClick,
}: DocumentUploaderProps) { }: DocumentUploaderProps) {
const uploaderId = useId(); const uploaderId = useId();
const enableDocx = useFeatureFlag('enableDocxConversion'); const enableDocx = useFeatureFlag('enableDocxConversion');
@ -88,8 +90,8 @@ export function DocumentUploader({
}, },
multiple: true, multiple: true,
disabled: isUploading, disabled: isUploading,
noClick: variant === 'overlay', noClick: variant === 'overlay' || !!onClick,
noKeyboard: variant === 'overlay' noKeyboard: variant === 'overlay' || !!onClick
}); });
const isDisabled = isUploading; const isDisabled = isUploading;
@ -132,6 +134,12 @@ export function DocumentUploader({
return ( return (
<div <div
{...getRootProps()} {...getRootProps()}
onClick={(e) => {
if (onClick) {
e.stopPropagation();
onClick();
}
}}
className={dropzoneSurfaceClass({ className={dropzoneSurfaceClass({
variant: variant === 'compact' ? 'compact' : 'default', variant: variant === 'compact' ? 'compact' : 'default',
active: isDragActive, active: isDragActive,

View file

@ -5,10 +5,7 @@ import toast from 'react-hot-toast';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import { importUrl } from '@/lib/client/api/documents'; import { importUrl } from '@/lib/client/api/documents';
import { import {
ModalFrame, SidebarDialog,
ModalTitle,
SidebarNav,
SidebarNavItem,
SegmentedControl, SegmentedControl,
Input, Input,
Textarea, Textarea,
@ -163,64 +160,15 @@ export function UploadMenuDialog({
}; };
return ( return (
<ModalFrame open={isOpen} onClose={handleClose} size="xl"> <SidebarDialog
<div className="flex flex-col h-[480px]"> open={isOpen}
{/* Header */} onClose={handleClose}
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft shrink-0"> headerTitle="Add Documents"
<ModalTitle>Add Documents</ModalTitle> sections={SIDEBAR_SECTIONS}
<button activeSectionId={activeTab}
onClick={handleClose} onSectionChange={handleTabChange}
className="rounded-md p-1 text-soft hover:bg-accent-wash hover:text-foreground transition-colors duration-base" className="h-[480px]"
aria-label="Close dialog"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Mobile Navigation (Horizontal row of items) */}
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2 shrink-0">
{SIDEBAR_SECTIONS.map((section) => {
const Icon = section.icon;
return (
<SidebarNavItem
compact
key={section.id}
onClick={() => handleTabChange(section.id)}
active={activeTab === section.id}
icon={<Icon className="w-3.5 h-3.5" />}
label={section.label}
/>
);
})}
</SidebarNav>
{/* Main Body (Split layout on desktop) */}
<div className="flex flex-row min-h-0 flex-1">
{/* Desktop Navigation (Left sidebar) */}
<nav className="hidden sm:block w-fit shrink-0 border-r border-line-soft bg-background p-2">
<SidebarNav>
{SIDEBAR_SECTIONS.map((section) => {
const Icon = section.icon;
const active = activeTab === section.id;
return (
<SidebarNavItem
key={section.id}
onClick={() => handleTabChange(section.id)}
active={active}
icon={<Icon className="w-4 h-4" />}
label={section.label}
className="whitespace-nowrap"
/>
);
})}
</SidebarNav>
</nav>
{/* Tab Panel Content (Right detail view) */}
<div className="flex-1 min-w-0 p-4 overflow-y-auto bg-surface">
{/* TAB 1: File Uploader */} {/* TAB 1: File Uploader */}
{activeTab === 'file' && ( {activeTab === 'file' && (
<div className="h-full flex flex-col gap-4 animate-fade-in"> <div className="h-full flex flex-col gap-4 animate-fade-in">
@ -352,7 +300,7 @@ export function UploadMenuDialog({
{/* Progress / Loading Stepper */} {/* Progress / Loading Stepper */}
{importStep !== 'idle' && ( {importStep !== 'idle' && (
<div className="rounded-lg border border-line bg-surface-sunken p-4 flex flex-col gap-3.5 transition-all duration-base"> <div className="rounded-lg border border-line bg-surface-sunken p-4 flex flex-col gap-3.5 transition-colors duration-base">
{importStep === 'error' ? ( {importStep === 'error' ? (
<div className="flex items-start gap-2 text-danger"> <div className="flex items-start gap-2 text-danger">
<svg className="h-5 w-5 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -439,9 +387,6 @@ export function UploadMenuDialog({
</div> </div>
</div> </div>
)} )}
</div> </SidebarDialog>
</div>
</div>
</ModalFrame>
); );
} }

View file

@ -23,3 +23,4 @@ export * from './switch';
export * from './tokens'; export * from './tokens';
export * from './toolbar'; export * from './toolbar';
export * from './variants'; export * from './variants';
export * from './sidebar-dialog';

View file

@ -0,0 +1,128 @@
'use client';
import { ReactNode } from 'react';
import { ModalFrame, ModalTitle, type DialogSize } from './dialog';
import { SidebarNav, SidebarNavItem } from './sidebar-nav';
import { cn } from './cn';
export type SidebarDialogSection<T extends string = string> = {
id: T;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
};
export function SidebarDialog<T extends string = string>({
open,
onClose,
size = 'xl',
panelTestId,
modalClassName,
headerTitle,
headerRight,
showCloseButton = true,
sections,
activeSectionId,
onSectionChange,
children,
className,
contentClassName,
customContent,
}: {
open: boolean;
onClose: () => void;
size?: DialogSize;
panelTestId?: string;
modalClassName?: string;
headerTitle: ReactNode;
headerRight?: ReactNode;
showCloseButton?: boolean;
sections: SidebarDialogSection<T>[];
activeSectionId: T;
onSectionChange: (id: T) => void;
children: ReactNode;
className?: string;
contentClassName?: string;
customContent?: ReactNode;
}) {
return (
<ModalFrame
open={open}
onClose={onClose}
size={size}
panelTestId={panelTestId}
className={modalClassName}
>
{customContent ? (
customContent
) : (
<div className={cn('flex flex-col h-[480px]', className)}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft shrink-0">
<div className="flex items-baseline gap-4">
<ModalTitle>{headerTitle}</ModalTitle>
</div>
<div className="flex items-center gap-2">
{headerRight}
{showCloseButton && (
<button
onClick={onClose}
className="rounded-md p-1 text-soft hover:bg-accent-wash hover:text-foreground transition-colors duration-base"
aria-label="Close dialog"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Mobile Navigation (Horizontal row of items) */}
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2 shrink-0">
{sections.map((section) => {
const Icon = section.icon;
return (
<SidebarNavItem
compact
key={section.id}
onClick={() => onSectionChange(section.id)}
active={activeSectionId === section.id}
icon={<Icon className="w-3.5 h-3.5" />}
label={section.label}
/>
);
})}
</SidebarNav>
{/* Main Body (Split layout on desktop) */}
<div className="flex flex-row min-h-0 flex-1">
{/* Desktop Navigation (Left sidebar) */}
<nav className="hidden sm:block w-fit shrink-0 border-r border-line-soft bg-background p-2">
<SidebarNav>
{sections.map((section) => {
const Icon = section.icon;
const active = activeSectionId === section.id;
return (
<SidebarNavItem
key={section.id}
onClick={() => onSectionChange(section.id)}
active={active}
icon={<Icon className="w-4 h-4" />}
label={section.label}
className="whitespace-nowrap"
/>
);
})}
</SidebarNav>
</nav>
{/* Tab Panel Content (Right detail view) */}
<div className={cn('flex-1 min-w-0 p-4 overflow-y-auto bg-surface', contentClassName)}>
{children}
</div>
</div>
</div>
)}
</ModalFrame>
);
}

View file

@ -15,7 +15,7 @@ function isPrivateIp(ip: string): boolean {
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipv4Match) { if (ipv4Match) {
const [, o1, o2, o3, o4] = ipv4Match.map(Number); const [, o1, o2] = ipv4Match.map(Number);
if (o1 === 10) return true; // 10.0.0.0/8 if (o1 === 10) return true; // 10.0.0.0/8
if (o1 === 172 && o2 >= 16 && o2 <= 31) return true; // 172.16.0.0/12 if (o1 === 172 && o2 >= 16 && o2 <= 31) return true; // 172.16.0.0/12
if (o1 === 192 && o2 === 168) return true; // 192.168.0.0/16 if (o1 === 192 && o2 === 168) return true; // 192.168.0.0/16
@ -211,7 +211,7 @@ export async function fetchAndParseUrl(
const turndownService = new TurndownService({ const turndownService = new TurndownService({
headingStyle: 'atx', headingStyle: 'atx',
hr: '---', hr: '---',
bullet: '-', bulletListMarker: '-',
codeBlockStyle: 'fenced', codeBlockStyle: 'fenced',
}); });