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:
parent
5a44a59ed1
commit
d86df06f9b
8 changed files with 402 additions and 364 deletions
|
|
@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { fetchAndParseUrl } from '@/lib/server/documents/web-loader';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
|
|||
import toast from 'react-hot-toast';
|
||||
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||
import {
|
||||
SidebarNav,
|
||||
SidebarDialog,
|
||||
SidebarNavItem,
|
||||
SegmentedControl,
|
||||
Button,
|
||||
|
|
@ -50,8 +50,6 @@ import {
|
|||
IconButton,
|
||||
Input,
|
||||
Textarea,
|
||||
ModalFrame,
|
||||
ModalTitle,
|
||||
Select,
|
||||
} from '@/components/ui';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
|
@ -562,90 +560,55 @@ export function SettingsModal({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ModalFrame
|
||||
<SidebarDialog
|
||||
open={isOpen}
|
||||
onClose={resetToCurrent}
|
||||
size="xl"
|
||||
panelTestId="settings-modal"
|
||||
className={isChangelogOpen ? 'z-[90]' : 'z-50'}
|
||||
modalClassName={isChangelogOpen ? 'z-[90]' : 'z-50'}
|
||||
headerTitle={
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span>Settings</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsChangelogOpen(true)}
|
||||
className="text-sm font-medium leading-6 text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => showPrivacyModal()}
|
||||
className="text-sm font-medium text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</Button>
|
||||
}
|
||||
showCloseButton={false}
|
||||
sections={visibleSections}
|
||||
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
|
||||
appVersion={runtimeConfig.appVersion}
|
||||
manifestUrl={runtimeConfig.changelogFeedUrl}
|
||||
onClose={() => setIsChangelogOpen(false)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<ModalTitle>Settings</ModalTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsChangelogOpen(true)}
|
||||
className="text-sm font-medium leading-6 text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => showPrivacyModal()}
|
||||
className="text-sm font-medium text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isChangelogOpen ? (
|
||||
<SettingsChangelogPanel
|
||||
appVersion={runtimeConfig.appVersion}
|
||||
manifestUrl={runtimeConfig.changelogFeedUrl}
|
||||
onClose={() => setIsChangelogOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 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 */}
|
||||
{activeSection === 'api' && (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -1174,11 +1137,7 @@ export function SettingsModal({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalFrame>
|
||||
</SidebarDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteAccountConfirm}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
|
||||
import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader';
|
||||
import { IconButton, SidebarNavItem } from '@/components/ui';
|
||||
import { UploadIcon } from '@/components/icons/Icons';
|
||||
import { IconButton } from '@/components/ui';
|
||||
import { UploadMenuDialog } from '@/components/documents/UploadMenuDialog';
|
||||
import { DocumentDndProvider } from './dnd/DocumentDndProvider';
|
||||
import {
|
||||
|
|
@ -623,11 +622,10 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
width={sidebarWidth}
|
||||
onWidthChange={setSidebarWidth}
|
||||
topSlot={(
|
||||
<SidebarNavItem
|
||||
compact
|
||||
<DocumentUploader
|
||||
variant="compact"
|
||||
onUploadBatchChange={handleUploadBatchChange}
|
||||
onClick={() => setIsUploadDialogOpen(true)}
|
||||
icon={<UploadIcon className="w-3.5 h-3.5" />}
|
||||
label="Upload documents"
|
||||
/>
|
||||
)}
|
||||
bottomSlot={(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface DocumentUploaderProps {
|
|||
variant?: 'default' | 'compact' | 'overlay';
|
||||
children?: ReactNode;
|
||||
onUploadBatchChange?: (state: UploadBatchState) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface UploadBatchState {
|
||||
|
|
@ -28,6 +29,7 @@ export function DocumentUploader({
|
|||
variant = 'default',
|
||||
children,
|
||||
onUploadBatchChange,
|
||||
onClick,
|
||||
}: DocumentUploaderProps) {
|
||||
const uploaderId = useId();
|
||||
const enableDocx = useFeatureFlag('enableDocxConversion');
|
||||
|
|
@ -88,8 +90,8 @@ export function DocumentUploader({
|
|||
},
|
||||
multiple: true,
|
||||
disabled: isUploading,
|
||||
noClick: variant === 'overlay',
|
||||
noKeyboard: variant === 'overlay'
|
||||
noClick: variant === 'overlay' || !!onClick,
|
||||
noKeyboard: variant === 'overlay' || !!onClick
|
||||
});
|
||||
|
||||
const isDisabled = isUploading;
|
||||
|
|
@ -132,6 +134,12 @@ export function DocumentUploader({
|
|||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
onClick={(e) => {
|
||||
if (onClick) {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={dropzoneSurfaceClass({
|
||||
variant: variant === 'compact' ? 'compact' : 'default',
|
||||
active: isDragActive,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import toast from 'react-hot-toast';
|
|||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import { importUrl } from '@/lib/client/api/documents';
|
||||
import {
|
||||
ModalFrame,
|
||||
ModalTitle,
|
||||
SidebarNav,
|
||||
SidebarNavItem,
|
||||
SidebarDialog,
|
||||
SegmentedControl,
|
||||
Input,
|
||||
Textarea,
|
||||
|
|
@ -163,285 +160,233 @@ export function UploadMenuDialog({
|
|||
};
|
||||
|
||||
return (
|
||||
<ModalFrame open={isOpen} onClose={handleClose} size="xl">
|
||||
<div className="flex flex-col h-[480px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft shrink-0">
|
||||
<ModalTitle>Add Documents</ModalTitle>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
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>
|
||||
<SidebarDialog
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
headerTitle="Add Documents"
|
||||
sections={SIDEBAR_SECTIONS}
|
||||
activeSectionId={activeTab}
|
||||
onSectionChange={handleTabChange}
|
||||
className="h-[480px]"
|
||||
>
|
||||
{/* TAB 1: File Uploader */}
|
||||
{activeTab === 'file' && (
|
||||
<div className="h-full flex flex-col gap-4 animate-fade-in">
|
||||
<p className="text-xs text-soft leading-relaxed shrink-0">
|
||||
Select files from your computer or drag and drop them anywhere. Supported formats include PDF, EPUB, TXT, and MD.
|
||||
</p>
|
||||
<DocumentUploader
|
||||
className="flex-1 flex flex-col justify-center border-2 border-dashed border-line rounded-lg bg-surface-sunken hover:bg-surface-solid transition-colors duration-base"
|
||||
onUploadBatchChange={(state) => {
|
||||
onUploadBatchChange?.(state);
|
||||
// Automatically close modal when files successfully start uploading
|
||||
if (state.isActive) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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}
|
||||
{/* TAB 2: Create Custom Document */}
|
||||
{activeTab === 'create' && (
|
||||
<div className="h-full flex flex-col justify-between animate-fade-in">
|
||||
<div className="space-y-3 flex-1 flex flex-col min-h-0">
|
||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={docName}
|
||||
onChange={(e) => setDocName(e.target.value)}
|
||||
placeholder="Document title (e.g. notes)"
|
||||
className="w-full font-medium"
|
||||
controlSize="md"
|
||||
disabled={isCreatingDoc}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl<'.md' | '.txt'>
|
||||
ariaLabel="Document format selection"
|
||||
value={docExtension}
|
||||
options={[
|
||||
{ value: '.md', label: 'Markdown (.md)' },
|
||||
{ value: '.txt', label: 'Plain Text (.txt)' },
|
||||
]}
|
||||
onChange={setDocExtension}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Textarea
|
||||
value={docContent}
|
||||
onChange={(e) => setDocContent(e.target.value)}
|
||||
placeholder={
|
||||
docExtension === '.md'
|
||||
? '# Write Markdown here\n\n- Bullet points\n- **Bold text**\n- Synchronized highlighting works automatically!'
|
||||
: 'Type plain text document content here...'
|
||||
}
|
||||
className="flex-1 min-h-[160px] sm:min-h-[200px] font-mono text-xs p-3 leading-relaxed resize-none"
|
||||
controlSize="md"
|
||||
disabled={isCreatingDoc}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarNav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<div className="flex items-center justify-between pt-3 mt-4 shrink-0">
|
||||
<span className="text-[11px] text-soft">
|
||||
{docContent.length} character{docContent.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDocName('');
|
||||
setDocContent('');
|
||||
}}
|
||||
disabled={isCreatingDoc}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateDocument}
|
||||
disabled={isCreatingDoc}
|
||||
>
|
||||
{isCreatingDoc ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Panel Content (Right detail view) */}
|
||||
<div className="flex-1 min-w-0 p-4 overflow-y-auto bg-surface">
|
||||
|
||||
{/* TAB 1: File Uploader */}
|
||||
{activeTab === 'file' && (
|
||||
<div className="h-full flex flex-col gap-4 animate-fade-in">
|
||||
<p className="text-xs text-soft leading-relaxed shrink-0">
|
||||
Select files from your computer or drag and drop them anywhere. Supported formats include PDF, EPUB, TXT, and MD.
|
||||
</p>
|
||||
<DocumentUploader
|
||||
className="flex-1 flex flex-col justify-center border-2 border-dashed border-line rounded-lg bg-surface-sunken hover:bg-surface-solid transition-colors duration-base"
|
||||
onUploadBatchChange={(state) => {
|
||||
onUploadBatchChange?.(state);
|
||||
// Automatically close modal when files successfully start uploading
|
||||
if (state.isActive) {
|
||||
onClose();
|
||||
{/* TAB 3: Import Web URL */}
|
||||
{activeTab === 'url' && (
|
||||
<div className="h-full flex flex-col justify-between animate-fade-in">
|
||||
<div className="space-y-4 flex-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="web-url" className="text-xs font-semibold text-foreground">
|
||||
Source Web URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="web-url"
|
||||
type="url"
|
||||
value={webUrl}
|
||||
onChange={(e) => setWebUrl(e.target.value)}
|
||||
placeholder="https://en.wikipedia.org/wiki/Speed_reading"
|
||||
className="flex-1"
|
||||
disabled={importStep !== 'idle' && importStep !== 'error'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleImportUrl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleImportUrl}
|
||||
disabled={importStep !== 'idle' && importStep !== 'error'}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-soft leading-normal mt-1">
|
||||
Extracts the central article body, removes boilerplate noise (headers, sidebars, ads), and converts it into a clean Markdown document for synchrony reading.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TAB 2: Create Custom Document */}
|
||||
{activeTab === 'create' && (
|
||||
<div className="h-full flex flex-col justify-between animate-fade-in">
|
||||
<div className="space-y-3 flex-1 flex flex-col min-h-0">
|
||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={docName}
|
||||
onChange={(e) => setDocName(e.target.value)}
|
||||
placeholder="Document title (e.g. notes)"
|
||||
className="w-full font-medium"
|
||||
controlSize="md"
|
||||
disabled={isCreatingDoc}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl<'.md' | '.txt'>
|
||||
ariaLabel="Document format selection"
|
||||
value={docExtension}
|
||||
options={[
|
||||
{ value: '.md', label: 'Markdown (.md)' },
|
||||
{ value: '.txt', label: 'Plain Text (.txt)' },
|
||||
]}
|
||||
onChange={setDocExtension}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Textarea
|
||||
value={docContent}
|
||||
onChange={(e) => setDocContent(e.target.value)}
|
||||
placeholder={
|
||||
docExtension === '.md'
|
||||
? '# Write Markdown here\n\n- Bullet points\n- **Bold text**\n- Synchronized highlighting works automatically!'
|
||||
: 'Type plain text document content here...'
|
||||
}
|
||||
className="flex-1 min-h-[160px] sm:min-h-[200px] font-mono text-xs p-3 leading-relaxed resize-none"
|
||||
controlSize="md"
|
||||
disabled={isCreatingDoc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 mt-4 shrink-0">
|
||||
<span className="text-[11px] text-soft">
|
||||
{docContent.length} character{docContent.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDocName('');
|
||||
setDocContent('');
|
||||
}}
|
||||
disabled={isCreatingDoc}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateDocument}
|
||||
disabled={isCreatingDoc}
|
||||
>
|
||||
{isCreatingDoc ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Import Web URL */}
|
||||
{activeTab === 'url' && (
|
||||
<div className="h-full flex flex-col justify-between animate-fade-in">
|
||||
<div className="space-y-4 flex-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="web-url" className="text-xs font-semibold text-foreground">
|
||||
Source Web URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="web-url"
|
||||
type="url"
|
||||
value={webUrl}
|
||||
onChange={(e) => setWebUrl(e.target.value)}
|
||||
placeholder="https://en.wikipedia.org/wiki/Speed_reading"
|
||||
className="flex-1"
|
||||
disabled={importStep !== 'idle' && importStep !== 'error'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleImportUrl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleImportUrl}
|
||||
disabled={importStep !== 'idle' && importStep !== 'error'}
|
||||
{/* Progress / Loading Stepper */}
|
||||
{importStep !== 'idle' && (
|
||||
<div className="rounded-lg border border-line bg-surface-sunken p-4 flex flex-col gap-3.5 transition-colors duration-base">
|
||||
{importStep === 'error' ? (
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">Import Failed</p>
|
||||
<p className="text-xs mt-1 text-danger-strong overflow-hidden text-ellipsis leading-relaxed">
|
||||
{importError || 'An unknown error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setImportStep('idle')}
|
||||
className="text-[11px] font-medium text-accent hover:underline mt-2 flex items-center gap-1"
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<RefreshIcon className="h-3 w-3" /> Try Again
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-soft leading-normal mt-1">
|
||||
Extracts the central article body, removes boilerplate noise (headers, sidebars, ads), and converts it into a clean Markdown document for synchrony reading.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress / Loading Stepper */}
|
||||
{importStep !== 'idle' && (
|
||||
<div className="rounded-lg border border-line bg-surface-sunken p-4 flex flex-col gap-3.5 transition-all duration-base">
|
||||
{importStep === 'error' ? (
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">Import Failed</p>
|
||||
<p className="text-xs mt-1 text-danger-strong overflow-hidden text-ellipsis leading-relaxed">
|
||||
{importError || 'An unknown error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setImportStep('idle')}
|
||||
className="text-[11px] font-medium text-accent hover:underline mt-2 flex items-center gap-1"
|
||||
>
|
||||
<RefreshIcon className="h-3 w-3" /> Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshIcon className="h-5 w-5 text-accent animate-spin" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Importing Article</p>
|
||||
<p className="text-xs text-soft">Please hold on, converting document...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Progress Steps */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 text-center text-[10px] font-medium">
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 ${
|
||||
importStep === 'fetching' ? 'text-accent' : 'text-soft'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-4 w-4 rounded-full flex items-center justify-center text-[9px] ${
|
||||
importStep === 'fetching'
|
||||
? 'bg-accent text-background'
|
||||
: importStep === 'converting' || importStep === 'uploading'
|
||||
? 'bg-accent-wash text-accent'
|
||||
: 'bg-surface border border-line'
|
||||
}`}>
|
||||
{importStep === 'converting' || importStep === 'uploading' ? (
|
||||
<CheckIcon className="h-2.5 w-2.5" />
|
||||
) : '1'}
|
||||
</span>
|
||||
Scraping Page
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 ${
|
||||
importStep === 'converting' ? 'text-accent' : 'text-soft'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-4 w-4 rounded-full flex items-center justify-center text-[9px] ${
|
||||
importStep === 'converting'
|
||||
? 'bg-accent text-background'
|
||||
: importStep === 'uploading'
|
||||
? 'bg-accent-wash text-accent'
|
||||
: 'bg-surface border border-line'
|
||||
}`}>
|
||||
{importStep === 'uploading' ? (
|
||||
<CheckIcon className="h-2.5 w-2.5" />
|
||||
) : '2'}
|
||||
</span>
|
||||
Extracting Text
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 ${
|
||||
importStep === 'uploading' ? 'text-accent' : 'text-soft'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-4 w-4 rounded-full flex items-center justify-center text-[9px] ${
|
||||
importStep === 'uploading' ? 'bg-accent text-background animate-pulse' : 'bg-surface border border-line'
|
||||
}`}>
|
||||
3
|
||||
</span>
|
||||
Uploading
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshIcon className="h-5 w-5 text-accent animate-spin" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Importing Article</p>
|
||||
<p className="text-xs text-soft">Please hold on, converting document...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual Progress Steps */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 text-center text-[10px] font-medium">
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 ${
|
||||
importStep === 'fetching' ? 'text-accent' : 'text-soft'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-4 w-4 rounded-full flex items-center justify-center text-[9px] ${
|
||||
importStep === 'fetching'
|
||||
? 'bg-accent text-background'
|
||||
: importStep === 'converting' || importStep === 'uploading'
|
||||
? 'bg-accent-wash text-accent'
|
||||
: 'bg-surface border border-line'
|
||||
}`}>
|
||||
{importStep === 'converting' || importStep === 'uploading' ? (
|
||||
<CheckIcon className="h-2.5 w-2.5" />
|
||||
) : '1'}
|
||||
</span>
|
||||
Scraping Page
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 ${
|
||||
importStep === 'converting' ? 'text-accent' : 'text-soft'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-4 w-4 rounded-full flex items-center justify-center text-[9px] ${
|
||||
importStep === 'converting'
|
||||
? 'bg-accent text-background'
|
||||
: importStep === 'uploading'
|
||||
? 'bg-accent-wash text-accent'
|
||||
: 'bg-surface border border-line'
|
||||
}`}>
|
||||
{importStep === 'uploading' ? (
|
||||
<CheckIcon className="h-2.5 w-2.5" />
|
||||
) : '2'}
|
||||
</span>
|
||||
Extracting Text
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 ${
|
||||
importStep === 'uploading' ? 'text-accent' : 'text-soft'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-4 w-4 rounded-full flex items-center justify-center text-[9px] ${
|
||||
importStep === 'uploading' ? 'bg-accent text-background animate-pulse' : 'bg-surface border border-line'
|
||||
}`}>
|
||||
3
|
||||
</span>
|
||||
Uploading
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
)}
|
||||
</SidebarDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@ export * from './switch';
|
|||
export * from './tokens';
|
||||
export * from './toolbar';
|
||||
export * from './variants';
|
||||
export * from './sidebar-dialog';
|
||||
|
|
|
|||
128
src/components/ui/sidebar-dialog.tsx
Normal file
128
src/components/ui/sidebar-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ function isPrivateIp(ip: string): boolean {
|
|||
|
||||
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
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 === 172 && o2 >= 16 && o2 <= 31) return true; // 172.16.0.0/12
|
||||
if (o1 === 192 && o2 === 168) return true; // 192.168.0.0/16
|
||||
|
|
@ -211,7 +211,7 @@ export async function fetchAndParseUrl(
|
|||
const turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
hr: '---',
|
||||
bullet: '-',
|
||||
bulletListMarker: '-',
|
||||
codeBlockStyle: 'fenced',
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue