feat(doclist): enhance document list UI with overlay uploader and improved file size formatting

Integrate DocumentUploader in overlay mode to wrap all document views, enabling drag-and-drop uploads across the entire list area. Refine file size formatting to support KB, MB, and GB units in all views and tiles. Standardize button scaling and padding for consistent UI rhythm. Update DocumentPreview and detail panels to display both type and human-readable size. Add overlay drag visual feedback for uploads.

These changes improve usability, visual clarity, and consistency in the document list interface.
This commit is contained in:
Richard R 2026-05-27 19:35:03 -06:00
parent 7a88dabc53
commit eb8820d888
11 changed files with 187 additions and 105 deletions

View file

@ -501,11 +501,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
onClick={() => {
void requestOpenSettings();
}}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.08] ${className}`}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`}
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.08] hover:rotate-45" />
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
</Button>
<Transition appear show={isOpen} as={Fragment}>
@ -1447,7 +1447,7 @@ function SettingsChangelogPanel({
<div className="flex items-center gap-3 px-4 py-3 border-b border-offbase bg-background">
<Button
onClick={onClose}
className="inline-flex items-center justify-center rounded-md text-muted hover:text-accent hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.08]"
className="inline-flex items-center justify-center rounded-md text-muted hover:text-accent hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.01]"
aria-label="Back to settings"
title="Back"
>

View file

@ -26,13 +26,13 @@ export function UserMenu({ className = '' }: { className?: string }) {
return (
<div className={`flex gap-2 ${className}`}>
<Link href="/signin">
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent">
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
Connect
</Button>
</Link>
{enableUserSignups && (
<Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01]">
Create account
</Button>
</Link>
@ -49,7 +49,7 @@ export function UserMenu({ className = '' }: { className?: string }) {
<Button
onClick={handleDisconnectAccount}
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.09]"
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.01]"
title="Disconnect account"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View file

@ -489,63 +489,65 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
</div>
)}
{fallbackViewMode === 'icons' && (
<IconsView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
iconSize={iconSize}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'list' && (
<ListView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
sortBy={sortBy}
sortDirection={sortDirection}
onSortChange={(b, d) => {
setSortBy(b);
setSortDirection(d);
}}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'columns' && (
<ColumnsView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'gallery' && (
<GalleryView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
<DocumentUploader variant="overlay" className="flex-1 min-h-0 flex flex-col">
{fallbackViewMode === 'icons' && (
<IconsView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
iconSize={iconSize}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'list' && (
<ListView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
sortBy={sortBy}
sortDirection={sortDirection}
onSortChange={(b, d) => {
setSortBy(b);
setSortDirection(d);
}}
collapsedFolders={collapsedFolders}
onToggleCollapse={toggleFolderCollapse}
onDeleteFolder={handleDeleteFolder}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'columns' && (
<ColumnsView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
{fallbackViewMode === 'gallery' && (
<GalleryView
folders={visibleFolders}
unfolderedDocs={unfolderedDocs}
onDeleteDoc={(doc) =>
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type })
}
onDropOnFolder={handleDropOnFolder}
onMergeIntoFolder={handleMergeIntoFolder}
/>
)}
</DocumentUploader>
<CreateFolderDialog
isOpen={pendingMerge !== null}

View file

@ -329,8 +329,16 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
</>
)}
<div className="absolute left-1 top-1 z-20 rounded bg-black/45 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-white">
{isGenerating ? '…' : typeLabel}
<div className="absolute left-1 bottom-1 z-20 rounded bg-black/45 px-1.5 py-0.5 text-[9px] font-semibold tracking-wide text-white/90">
{isGenerating
? '…'
: `${typeLabel}${
doc.size >= 1024 * 1024 * 1024
? `${(doc.size / 1024 / 1024 / 1024).toFixed(2)} GB`
: doc.size >= 1024 * 1024
? `${(doc.size / 1024 / 1024).toFixed(2)} MB`
: `${(doc.size / 1024).toFixed(1)} KB`
}`}
</div>
</div>
);

View file

@ -216,7 +216,12 @@ export function ColumnsView({
{selected.doc.name}
</h3>
<p className="text-[11px] text-muted">
{selected.doc.type.toUpperCase()} {(selected.doc.size / 1024 / 1024).toFixed(2)} MB
{selected.doc.type.toUpperCase()} {' '}
{selected.doc.size >= 1024 * 1024 * 1024
? `${(selected.doc.size / 1024 / 1024 / 1024).toFixed(2)} GB`
: selected.doc.size >= 1024 * 1024
? `${(selected.doc.size / 1024 / 1024).toFixed(2)} MB`
: `${(selected.doc.size / 1024).toFixed(1)} KB`}
</p>
<div className="mt-3 flex gap-2">
<Link

View file

@ -9,7 +9,6 @@ import type { DocumentListDocument, IconSize } from '@/types/documents';
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { buttonClass } from '@/components/formPrimitives';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
@ -111,9 +110,51 @@ export function DocumentTile({
const sizeClasses: Record<IconSize, string> = {
sm: 'text-[10px]',
md: 'text-[12px]',
lg: 'text-[13px]',
xl: 'text-[14px]',
md: 'text-[11px]',
lg: 'text-[12px]',
xl: 'text-[13px]',
};
const bottomPaddingClasses: Record<IconSize, string> = {
sm: 'px-[7px] py-[4px]',
md: 'px-[8px] py-[5px]',
lg: 'px-[9px] py-[5px]',
xl: 'px-[10px] py-[6px]',
};
const linkPaddingClasses: Record<IconSize, string> = {
sm: 'px-[2px] py-[2px]',
md: 'px-[2px] py-[2px]',
lg: 'px-[2px] py-[2px]',
xl: 'px-[2px] py-[2px]',
};
const gapClasses: Record<IconSize, string> = {
sm: 'gap-1',
md: 'gap-1.5',
lg: 'gap-2',
xl: 'gap-2',
};
const fileIconClasses: Record<IconSize, string> = {
sm: 'w-3 h-3',
md: 'w-3.5 h-3.5',
lg: 'w-3.5 h-3.5',
xl: 'w-4 h-4',
};
const trashBtnClasses: Record<IconSize, string> = {
sm: 'ml-0.5 h-[18px] w-[18px] rounded-sm',
md: 'ml-0.5 h-[21px] w-[21px] rounded-sm',
lg: 'ml-1 h-[23px] w-[23px] rounded',
xl: 'ml-1.5 h-[25px] w-[25px] rounded',
};
const trashIconClasses: Record<IconSize, string> = {
sm: 'w-[10px] h-[10px]',
md: 'w-[11px] h-[11px]',
lg: 'w-[12px] h-[12px]',
xl: 'w-[13px] h-[13px]',
};
return (
@ -140,49 +181,40 @@ export function DocumentTile({
>
<DocumentPreview doc={doc} />
</Link>
<div className="flex items-center w-full px-1.5 py-1.5">
<div className={`flex items-center w-full ${bottomPaddingClasses[iconSize]}`}>
<Link
href={href}
draggable={false}
className="flex items-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
className={`flex items-center flex-1 min-w-0 rounded-md ${linkPaddingClasses[iconSize]} ${gapClasses[iconSize]}`}
onClick={handleClick}
>
<span className="flex-shrink-0">
<span className="flex-shrink-0 flex items-center">
{doc.type === 'pdf' ? (
<PDFIcon className="w-4 h-4 text-red-500" />
<PDFIcon className={`${fileIconClasses[iconSize]} text-red-500`} />
) : doc.type === 'epub' ? (
<EPUBIcon className="w-4 h-4 text-blue-500" />
<EPUBIcon className={`${fileIconClasses[iconSize]} text-blue-500`} />
) : (
<FileIcon className="w-4 h-4 text-muted" />
<FileIcon className={`${fileIconClasses[iconSize]} text-muted`} />
)}
</span>
<span className="flex flex-col min-w-0 w-full">
<span
className={
'leading-tight font-medium truncate ' +
sizeClasses[iconSize] +
' ' +
(isSelected ? 'text-accent' : 'text-foreground group-hover:text-accent')
}
>
{doc.name}
</span>
<span className="text-[9px] leading-tight text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</span>
<span
className={
'leading-none font-medium truncate flex-1 min-w-0 ' +
sizeClasses[iconSize] +
' ' +
(isSelected ? 'text-accent' : 'text-foreground group-hover:text-accent')
}
>
{doc.name}
</span>
</Link>
{showDeleteButton && (
<Button
onClick={() => onDelete(doc)}
className={buttonClass({
variant: 'ghost',
size: 'icon',
className: 'ml-1 h-6 w-6 text-muted hover:bg-base',
})}
className={`inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base focus:outline-none transition-colors duration-200 ${trashBtnClasses[iconSize]}`}
aria-label={`Delete ${doc.name}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className={trashIconClasses[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"

View file

@ -181,7 +181,11 @@ export function GalleryView({
</h2>
<p className="text-[11px] text-muted">
{activeDoc.type.toUpperCase()} {' '}
{(activeDoc.size / 1024 / 1024).toFixed(2)} MB
{activeDoc.size >= 1024 * 1024 * 1024
? `${(activeDoc.size / 1024 / 1024 / 1024).toFixed(2)} GB`
: activeDoc.size >= 1024 * 1024
? `${(activeDoc.size / 1024 / 1024).toFixed(2)} MB`
: `${(activeDoc.size / 1024).toFixed(1)} KB`}
</p>
</div>
<div className="flex gap-2">

View file

@ -36,6 +36,9 @@ function formatDate(ms: number): string {
function formatSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
}
return mb >= 1 ? `${mb.toFixed(2)} MB` : `${(bytes / 1024).toFixed(1)} KB`;
}

View file

@ -47,7 +47,7 @@ function SidebarRow({
type="button"
onClick={onClick}
className={
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.02] ' +
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.01] ' +
(active
? 'border-accent bg-offbase text-accent'
: 'border-transparent text-foreground hover:border-accent hover:bg-offbase hover:text-accent') +

View file

@ -58,10 +58,9 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm.
const TOOLBAR_BTN =
'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.02]';
'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]';
const TOOLBAR_BTN_INACTIVE =
'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase';
const TOOLBAR_BTN_ACTIVE = 'border-accent text-accent bg-offbase';
// Pill-grouped segmented control. Outer pill carries the border; inner segments are
// borderless and rely on bg/text color to show active/hover. Sized so the whole pill

View file

@ -1,6 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, type ReactNode } from 'react';
import { useDropzone } from 'react-dropzone';
import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
@ -10,10 +10,11 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
interface DocumentUploaderProps {
className?: string;
variant?: 'default' | 'compact';
variant?: 'default' | 'compact' | 'overlay';
children?: ReactNode;
}
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
export function DocumentUploader({ className = '', variant = 'default', children }: DocumentUploaderProps) {
const enableDocx = useFeatureFlag('enableDocxConversion');
const {
addPDFDocument: addPDF,
@ -71,11 +72,13 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
} : {})
},
multiple: true,
disabled: isUploading || isConverting
disabled: isUploading || isConverting,
noClick: variant === 'overlay',
noKeyboard: variant === 'overlay'
});
const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${
variant === 'compact' ? 'hover:scale-[1.02]' : ''
variant === 'compact' ? 'hover:scale-[1.01]' : ''
} ${
isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
} ${className}`;
@ -95,6 +98,32 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg';
if (variant === 'overlay') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onClick, onKeyDown, ...rootProps } = getRootProps();
return (
<div {...rootProps} className={`relative w-full h-full ${className}`}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/90 backdrop-blur-md pointer-events-none p-6">
<div className="w-full h-full border-2 border-dashed border-accent rounded-xl flex flex-col items-center justify-center bg-base/60 text-center p-4">
<UploadIcon className="w-14 h-14 text-accent mb-4 animate-bounce" />
<p className="text-xl font-bold text-foreground mb-1.5">
Drop files here to upload
</p>
<p className="text-sm text-foreground/70">
{enableDocx
? 'Accepts PDF, EPUB, TXT, MD, or DOCX'
: 'Accepts PDF, EPUB, TXT, or MD'}
</p>
</div>
</div>
)}
</div>
);
}
return (
<div
{...getRootProps()}