refactor(doclist): redesign document list with new views, DnD, and windowed UI
Revamp the document list experience by introducing a Finder-style window interface with multiple views (icons, list, columns, gallery) and a new sidebar filter system. Remove legacy folder and list item components in favor of modular, windowed views. Integrate react-dnd-touch-backend and custom DnD context for improved drag-and-drop, including mobile support. Update uploader and dialog styles for consistency. Extend document types to support new view modes, icon sizing, and sidebar state.
This commit is contained in:
parent
3db193e012
commit
7a88dabc53
25 changed files with 2748 additions and 777 deletions
|
|
@ -64,6 +64,7 @@
|
|||
"react": "^19.2.6",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dnd-touch-backend": "^16.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,9 @@ importers:
|
|||
react-dnd-html5-backend:
|
||||
specifier: ^16.0.1
|
||||
version: 16.0.1
|
||||
react-dnd-touch-backend:
|
||||
specifier: ^16.0.1
|
||||
version: 16.0.1
|
||||
react-dom:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6(react@19.2.6)
|
||||
|
|
@ -4249,6 +4252,9 @@ packages:
|
|||
react-dnd-html5-backend@16.0.1:
|
||||
resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==}
|
||||
|
||||
react-dnd-touch-backend@16.0.1:
|
||||
resolution: {integrity: sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==}
|
||||
|
||||
react-dnd@16.0.1:
|
||||
resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==}
|
||||
peerDependencies:
|
||||
|
|
@ -9307,6 +9313,11 @@ snapshots:
|
|||
dependencies:
|
||||
dnd-core: 16.0.1
|
||||
|
||||
react-dnd-touch-backend@16.0.1:
|
||||
dependencies:
|
||||
'@react-dnd/invariant': 4.0.2
|
||||
dnd-core: 16.0.1
|
||||
|
||||
react-dnd@16.0.1(@types/node@20.19.41)(@types/react@19.2.14)(react@19.2.6):
|
||||
dependencies:
|
||||
'@react-dnd/invariant': 4.0.2
|
||||
|
|
|
|||
|
|
@ -1,30 +1,12 @@
|
|||
import { Header } from '@/components/Header';
|
||||
import { HomeContent } from '@/components/HomeContent';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { UserMenu } from '@/components/auth/UserMenu';
|
||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<Header
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/icon.svg" alt="" className="w-5 h-5" aria-hidden="true" />
|
||||
<h1 className="text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">OpenReader</h1>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsModal />
|
||||
<UserMenu />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<RateLimitBanner className="mb-6" />
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<RateLimitBanner className="mx-2 mt-2" />
|
||||
<div className="flex-1 min-h-0">
|
||||
<HomeContent />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||
githubAuthEnabled={githubAuthEnabled}
|
||||
>
|
||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden">
|
||||
<main className="flex-1 min-h-0 flex flex-col">{children}</main>
|
||||
</div>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
|
|
|
|||
|
|
@ -1,33 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { Header } from '@/components/Header';
|
||||
import { DocumentUploader } from '@/components/documents/DocumentUploader';
|
||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { UserMenu } from '@/components/auth/UserMenu';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
|
||||
const Brand = () => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/icon.svg" alt="" className="w-5 h-5 shrink-0" aria-hidden="true" />
|
||||
<h1 className="hidden sm:block text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">
|
||||
OpenReader
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppActions = () => (
|
||||
<>
|
||||
<SettingsModal />
|
||||
<UserMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
export function HomeContent() {
|
||||
const { pdfDocs, epubDocs, htmlDocs, isPDFLoading } = useDocuments();
|
||||
const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0);
|
||||
|
||||
if (isPDFLoading) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DocumentListSkeleton />
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<Header title={<Brand />} right={<AppActions />} />
|
||||
<div className="flex-1 min-h-0 p-3 overflow-auto">
|
||||
<DocumentListSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalDocs === 0) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DocumentUploader className="py-12" />
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<Header title={<Brand />} right={<AppActions />} />
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||
<DocumentUploader className="py-12 w-full max-w-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DocumentList />
|
||||
<div className="w-full h-full">
|
||||
<DocumentList brand={<Brand />} appActions={<AppActions />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function CreateFolderDialog({
|
|||
onChange={(e) => onFolderNameChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Enter folder name"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted">Press Enter to create or Escape to cancel</p>
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import { useState, DragEvent } from 'react';
|
||||
import { Button, Transition } from '@headlessui/react';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
import { Folder, DocumentListDocument } from '@/types/documents';
|
||||
|
||||
interface DocumentFolderProps {
|
||||
folder: Folder;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (folderId: string) => void;
|
||||
onDelete: () => void;
|
||||
sortedDocuments: DocumentListDocument[];
|
||||
onDocumentDelete: (doc: DocumentListDocument) => void;
|
||||
draggedDoc: DocumentListDocument | null;
|
||||
onDragStart: (doc: DocumentListDocument) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: (e: DragEvent, folderId: string) => void;
|
||||
viewMode: 'list' | 'grid';
|
||||
}
|
||||
|
||||
const ChevronIcon = ({ className = "w-4 h-4" }) => (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const calculateFolderSize = (documents: DocumentListDocument[]) => {
|
||||
return documents.reduce((total, doc) => total + doc.size, 0);
|
||||
};
|
||||
|
||||
export function DocumentFolder({
|
||||
folder,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDelete,
|
||||
sortedDocuments,
|
||||
onDocumentDelete,
|
||||
draggedDoc,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
viewMode,
|
||||
}: DocumentFolderProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (draggedDoc && !draggedDoc.folderId) {
|
||||
setIsHovering(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsHovering(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsHovering(false);
|
||||
if (!draggedDoc || draggedDoc.folderId) return;
|
||||
onDrop(e, folder.id);
|
||||
}}
|
||||
className={`w-full overflow-hidden rounded-md border border-offbase ${viewMode === 'grid' ? 'col-span-full' : ''} ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
||||
>
|
||||
<div className='flex flex-row justify-between p-0'>
|
||||
<div className="w-full">
|
||||
<div className={`flex items-center justify-between px-2 py-1 bg-offbase rounded-t-md border-b border-offbase transition-all duration-200`}>
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onToggleCollapse(folder.id)}
|
||||
className="ml-0.5 p-1 inline-flex items-center justify-center transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
|
||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-controls={`folder-panel-${folder.id}`}
|
||||
title={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
>
|
||||
<ChevronIcon className={`w-4 h-4 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-muted hover:text-accent hover:bg-base rounded-md transition-colors"
|
||||
aria-label="Delete folder"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5"
|
||||
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 className="relative bg-base px-1 py-1 rounded-b-md">
|
||||
<Transition
|
||||
show={!isCollapsed}
|
||||
enter="transition-all duration-300 ease-out"
|
||||
enterFrom="transform scale-y-0 opacity-0 max-h-0"
|
||||
enterTo="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||
leave="transition-all duration-200 ease-in"
|
||||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
id={`folder-panel-${folder.id}`}
|
||||
className={`${
|
||||
viewMode === 'grid'
|
||||
? 'grid w-full grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5'
|
||||
: 'w-full space-y-1'
|
||||
} origin-top`}
|
||||
>
|
||||
{sortedDocuments.map(doc => (
|
||||
<DocumentListItem
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
onDelete={onDocumentDelete}
|
||||
dragEnabled={false} // Documents in folders can't be dragged to other documents
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
isDropTarget={false}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
show={isCollapsed}
|
||||
enter="transition-all duration-200"
|
||||
enterFrom="max-h-0 opacity-0"
|
||||
enterTo="max-h-[50px] opacity-100"
|
||||
leave="transition-all duration-100"
|
||||
leaveFrom="max-h-[50px] opacity-100"
|
||||
leaveTo="max-h-0 opacity-0"
|
||||
>
|
||||
<p className="text-[10px] px-1 text-left text-muted leading-tight">
|
||||
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
|
||||
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,410 +1,609 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useState, useEffect, DragEvent, KeyboardEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
|
||||
import type {
|
||||
DocumentListDocument,
|
||||
DocumentListState,
|
||||
Folder,
|
||||
IconSize,
|
||||
SidebarFilter,
|
||||
SortBy,
|
||||
SortDirection,
|
||||
ViewMode,
|
||||
} from '@/types/documents';
|
||||
import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
|
||||
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
||||
import { SortControls } from '@/components/doclist/SortControls';
|
||||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentUploader } from '@/components/documents/DocumentUploader';
|
||||
import { buttonClass } from '@/components/formPrimitives';
|
||||
import { DocumentDndProvider } from './dnd/DocumentDndProvider';
|
||||
import {
|
||||
DocumentSelectionProvider,
|
||||
useDocumentSelection,
|
||||
} from './dnd/DocumentSelectionContext';
|
||||
import type { DocumentDragItem } from './dnd/dndTypes';
|
||||
import { FinderWindow, useIsNarrow } from './window/FinderWindow';
|
||||
import { FinderToolbar } from './window/FinderToolbar';
|
||||
import { FinderSidebar } from './window/FinderSidebar';
|
||||
import { FinderStatusBar } from './window/FinderStatusBar';
|
||||
import { IconsView } from './views/IconsView';
|
||||
import { ListView } from './views/ListView';
|
||||
import { ColumnsView } from './views/ColumnsView';
|
||||
import { GalleryView } from './views/GalleryView';
|
||||
|
||||
type DocumentToDelete = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: DocumentType;
|
||||
type: DocumentListDocument['type'];
|
||||
};
|
||||
|
||||
const generateDefaultFolderName = (doc1: DocumentListDocument, doc2: DocumentListDocument) => {
|
||||
// Try to find common words between the two document names
|
||||
const words1 = doc1.name.toLowerCase().split(/[\s-_\.]+/);
|
||||
const words2 = doc2.name.toLowerCase().split(/[\s-_\.]+/);
|
||||
const commonWords = words1.filter(word => words2.includes(word));
|
||||
const DEFAULT_STATE: Required<
|
||||
Pick<
|
||||
DocumentListState,
|
||||
'sortBy' | 'sortDirection' | 'folders' | 'collapsedFolders' | 'showHint'
|
||||
>
|
||||
> & {
|
||||
viewMode: ViewMode;
|
||||
iconSize: IconSize;
|
||||
sidebarWidth: number;
|
||||
sidebarFilter: SidebarFilter;
|
||||
sidebarCollapsed: boolean;
|
||||
} = {
|
||||
sortBy: 'name',
|
||||
sortDirection: 'asc',
|
||||
folders: [],
|
||||
collapsedFolders: [],
|
||||
showHint: true,
|
||||
viewMode: 'icons',
|
||||
iconSize: 'md',
|
||||
sidebarWidth: 220,
|
||||
sidebarFilter: 'all',
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
|
||||
if (commonWords.length > 0) {
|
||||
// Use the first common word that's at least 3 characters long
|
||||
const significant = commonWords.find(word => word.length >= 3);
|
||||
if (significant) {
|
||||
if (significant === 'pdf') return 'PDFs';
|
||||
if (significant === 'epub') return 'EPUBs';
|
||||
if (significant === 'txt' || significant === 'md') return 'Documents';
|
||||
return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
|
||||
}
|
||||
function normalizeViewMode(stored: DocumentListState['viewMode']): ViewMode {
|
||||
if (stored === 'grid' || stored === undefined) return 'icons';
|
||||
if (stored === 'list') return 'list';
|
||||
return stored;
|
||||
}
|
||||
|
||||
function generateDefaultFolderName(
|
||||
doc1: DocumentListDocument,
|
||||
doc2: DocumentListDocument,
|
||||
): string {
|
||||
const words1 = doc1.name.toLowerCase().split(/[\s\-_.]+/);
|
||||
const words2 = doc2.name.toLowerCase().split(/[\s\-_.]+/);
|
||||
const common = words1.filter((w) => words2.includes(w));
|
||||
const significant = common.find((w) => w.length >= 3);
|
||||
if (significant) {
|
||||
if (significant === 'pdf') return 'PDFs';
|
||||
if (significant === 'epub') return 'EPUBs';
|
||||
if (significant === 'txt' || significant === 'md') return 'Documents';
|
||||
return significant.charAt(0).toUpperCase() + significant.slice(1);
|
||||
}
|
||||
|
||||
// Fallback to a numbered folder
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
return `Folder ${timestamp}`;
|
||||
};
|
||||
}
|
||||
|
||||
export function DocumentList() {
|
||||
// State hooks
|
||||
const [sortBy, setSortBy] = useState<SortBy>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||
const [draggedDoc, setDraggedDoc] = useState<DocumentListDocument | null>(null);
|
||||
const [dropTargetDoc, setDropTargetDoc] = useState<DocumentListDocument | null>(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null);
|
||||
function sortDocs(
|
||||
docs: DocumentListDocument[],
|
||||
sortBy: SortBy,
|
||||
direction: SortDirection,
|
||||
): DocumentListDocument[] {
|
||||
const sorted = [...docs].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'type':
|
||||
return a.type.localeCompare(b.type);
|
||||
case 'size':
|
||||
return a.size - b.size;
|
||||
default:
|
||||
return a.lastModified - b.lastModified;
|
||||
}
|
||||
});
|
||||
return direction === 'asc' ? sorted : sorted.reverse();
|
||||
}
|
||||
|
||||
interface DocumentListInnerProps {
|
||||
brand?: ReactNode;
|
||||
appActions?: ReactNode;
|
||||
}
|
||||
|
||||
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||
const [sortBy, setSortBy] = useState<SortBy>(DEFAULT_STATE.sortBy);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(
|
||||
DEFAULT_STATE.sortDirection,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(DEFAULT_STATE.viewMode);
|
||||
const [iconSize, setIconSize] = useState<IconSize>(DEFAULT_STATE.iconSize);
|
||||
const [folders, setFolders] = useState<Folder[]>(DEFAULT_STATE.folders);
|
||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showHint, setShowHint] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth);
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||
const [pendingMerge, setPendingMerge] = useState<
|
||||
| { sources: DocumentListDocument[]; target: DocumentListDocument }
|
||||
| null
|
||||
>(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [manualFolderPrompt, setManualFolderPrompt] = useState(false);
|
||||
|
||||
const isNarrow = useIsNarrow();
|
||||
const selection = useDocumentSelection();
|
||||
|
||||
const {
|
||||
pdfDocs,
|
||||
removePDFDocument: removePDF,
|
||||
removePDFDocument,
|
||||
isPDFLoading,
|
||||
epubDocs,
|
||||
removeEPUBDocument: removeEPUB,
|
||||
removeEPUBDocument,
|
||||
isEPUBLoading,
|
||||
htmlDocs,
|
||||
removeHTMLDocument: removeHTML,
|
||||
removeHTMLDocument,
|
||||
isHTMLLoading,
|
||||
} = useDocuments();
|
||||
|
||||
// Load saved state.
|
||||
useEffect(() => {
|
||||
// Load saved state
|
||||
const loadState = async () => {
|
||||
const savedState = await getDocumentListState();
|
||||
if (savedState) {
|
||||
setSortBy(savedState.sortBy);
|
||||
setSortDirection(savedState.sortDirection);
|
||||
setFolders(savedState.folders);
|
||||
setCollapsedFolders(new Set(savedState.collapsedFolders));
|
||||
setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
|
||||
setViewMode(savedState.viewMode ?? 'grid');
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const saved = await getDocumentListState();
|
||||
if (cancelled) return;
|
||||
if (saved) {
|
||||
setSortBy(saved.sortBy);
|
||||
setSortDirection(saved.sortDirection);
|
||||
setFolders(saved.folders ?? []);
|
||||
setCollapsedFolders(new Set(saved.collapsedFolders ?? []));
|
||||
setShowHint(saved.showHint ?? true);
|
||||
setViewMode(normalizeViewMode(saved.viewMode));
|
||||
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
|
||||
setSidebarWidth(saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
|
||||
setSidebarFilter(saved.sidebarFilter ?? 'all');
|
||||
setSidebarOpen(!(saved.sidebarCollapsed ?? false));
|
||||
}
|
||||
setIsInitialized(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
loadState();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saveState = async () => {
|
||||
const state: DocumentListState = {
|
||||
sortBy,
|
||||
sortDirection,
|
||||
folders,
|
||||
collapsedFolders: Array.from(collapsedFolders),
|
||||
showHint,
|
||||
viewMode
|
||||
};
|
||||
await saveDocumentListState(state);
|
||||
};
|
||||
|
||||
if (isInitialized) { // Prevents saving empty state on first render or back navigation
|
||||
saveState();
|
||||
}
|
||||
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]);
|
||||
|
||||
// Reconcile folder state against the current server-backed document list.
|
||||
// If a document no longer exists on the server, drop it from folders to avoid stale UI.
|
||||
// Persist.
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
const ids = new Set<string>([...pdfDocs, ...epubDocs, ...htmlDocs].map((d) => d.id));
|
||||
const state: DocumentListState = {
|
||||
sortBy,
|
||||
sortDirection,
|
||||
folders,
|
||||
collapsedFolders: Array.from(collapsedFolders),
|
||||
showHint,
|
||||
viewMode,
|
||||
iconSize,
|
||||
sidebarWidth,
|
||||
sidebarFilter,
|
||||
sidebarCollapsed: !sidebarOpen,
|
||||
};
|
||||
void saveDocumentListState(state);
|
||||
}, [
|
||||
sortBy,
|
||||
sortDirection,
|
||||
folders,
|
||||
collapsedFolders,
|
||||
showHint,
|
||||
viewMode,
|
||||
iconSize,
|
||||
sidebarWidth,
|
||||
sidebarFilter,
|
||||
sidebarOpen,
|
||||
isInitialized,
|
||||
]);
|
||||
|
||||
// Build the union document list.
|
||||
const allDocuments: DocumentListDocument[] = useMemo(
|
||||
() => [
|
||||
...pdfDocs.map((d) => ({ ...d, type: 'pdf' as const })),
|
||||
...epubDocs.map((d) => ({ ...d, type: 'epub' as const })),
|
||||
...htmlDocs.map((d) => ({ ...d, type: 'html' as const })),
|
||||
],
|
||||
[pdfDocs, epubDocs, htmlDocs],
|
||||
);
|
||||
|
||||
// Reconcile folders against server.
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
const ids = new Set(allDocuments.map((d) => d.id));
|
||||
setFolders((prev) =>
|
||||
prev.map((folder) => ({
|
||||
...folder,
|
||||
documents: folder.documents.filter((d) => ids.has(d.id)),
|
||||
prev.map((f) => ({
|
||||
...f,
|
||||
documents: f.documents.filter((d) => ids.has(d.id)),
|
||||
})),
|
||||
);
|
||||
}, [isInitialized, pdfDocs, epubDocs, htmlDocs]);
|
||||
}, [isInitialized, allDocuments]);
|
||||
|
||||
const allDocuments: DocumentListDocument[] = [
|
||||
...pdfDocs.map((doc) => ({ ...doc, type: 'pdf' as const })),
|
||||
...epubDocs.map((doc) => ({ ...doc, type: 'epub' as const })),
|
||||
...htmlDocs.map((doc) => ({ ...doc, type: 'html' as const })),
|
||||
];
|
||||
// Filter based on sidebar selection + search query.
|
||||
const visibleAll = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
let docs = allDocuments;
|
||||
if (sidebarFilter === 'pdf') docs = docs.filter((d) => d.type === 'pdf');
|
||||
else if (sidebarFilter === 'epub') docs = docs.filter((d) => d.type === 'epub');
|
||||
else if (sidebarFilter === 'html') docs = docs.filter((d) => d.type === 'html');
|
||||
else if (sidebarFilter === 'recents') {
|
||||
docs = [...docs]
|
||||
.sort((a, b) => b.lastModified - a.lastModified)
|
||||
.slice(0, 20);
|
||||
} else if (sidebarFilter.startsWith('folder:')) {
|
||||
const fid = sidebarFilter.slice('folder:'.length);
|
||||
const folder = folders.find((f) => f.id === fid);
|
||||
docs = folder ? folder.documents : [];
|
||||
}
|
||||
if (q) docs = docs.filter((d) => d.name.toLowerCase().includes(q));
|
||||
return docs;
|
||||
}, [allDocuments, sidebarFilter, query, folders]);
|
||||
|
||||
const sortDocuments = useCallback((docs: DocumentListDocument[]) => {
|
||||
return [...docs].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return sortDirection === 'asc'
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name);
|
||||
case 'type':
|
||||
return sortDirection === 'asc'
|
||||
? a.type.localeCompare(b.type)
|
||||
: b.type.localeCompare(a.type);
|
||||
case 'size':
|
||||
return sortDirection === 'asc'
|
||||
? a.size - b.size
|
||||
: b.size - a.size;
|
||||
default:
|
||||
return sortDirection === 'asc'
|
||||
? a.lastModified - b.lastModified
|
||||
: b.lastModified - a.lastModified;
|
||||
}
|
||||
});
|
||||
}, [sortBy, sortDirection]);
|
||||
// Apply sort.
|
||||
const sortedVisible = useMemo(
|
||||
() => sortDocs(visibleAll, sortBy, sortDirection),
|
||||
[visibleAll, sortBy, sortDirection],
|
||||
);
|
||||
|
||||
// Split into folders + unfoldered for the current filter context.
|
||||
const showAllScope =
|
||||
sidebarFilter === 'all' || sidebarFilter === 'recents';
|
||||
const visibleFolders = useMemo<Folder[]>(() => {
|
||||
if (!showAllScope) return [];
|
||||
// Within a kind-filter (pdf/epub/html), still show folders that contain matches.
|
||||
return folders.map((f) => ({
|
||||
...f,
|
||||
documents: sortDocs(
|
||||
f.documents.filter((d) =>
|
||||
query ? d.name.toLowerCase().includes(query.toLowerCase()) : true,
|
||||
),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
),
|
||||
}));
|
||||
}, [folders, showAllScope, query, sortBy, sortDirection]);
|
||||
|
||||
const unfolderedDocs = useMemo(() => {
|
||||
const inFolder = new Set<string>();
|
||||
folders.forEach((f) => f.documents.forEach((d) => inFolder.add(d.id)));
|
||||
return sortedVisible.filter((d) => !inFolder.has(d.id));
|
||||
}, [folders, sortedVisible]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allDocuments.length,
|
||||
pdf: pdfDocs.length,
|
||||
epub: epubDocs.length,
|
||||
html: htmlDocs.length,
|
||||
}),
|
||||
[allDocuments.length, pdfDocs.length, epubDocs.length, htmlDocs.length],
|
||||
);
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!documentToDelete) return;
|
||||
|
||||
try {
|
||||
if (documentToDelete.type === 'pdf') {
|
||||
await removePDF(documentToDelete.id);
|
||||
} else if (documentToDelete.type === 'epub') {
|
||||
await removeEPUB(documentToDelete.id);
|
||||
} else if (documentToDelete.type === 'html') {
|
||||
await removeHTML(documentToDelete.id);
|
||||
}
|
||||
|
||||
// Remove from folders if document is in one
|
||||
setFolders(prev => prev.map(folder => ({
|
||||
...folder,
|
||||
documents: folder.documents.filter(doc =>
|
||||
!(doc.id === documentToDelete.id && doc.type === documentToDelete.type)
|
||||
)
|
||||
})));
|
||||
|
||||
if (documentToDelete.type === 'pdf') await removePDFDocument(documentToDelete.id);
|
||||
else if (documentToDelete.type === 'epub') await removeEPUBDocument(documentToDelete.id);
|
||||
else if (documentToDelete.type === 'html') await removeHTMLDocument(documentToDelete.id);
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => ({
|
||||
...f,
|
||||
documents: f.documents.filter(
|
||||
(d) => !(d.id === documentToDelete.id && d.type === documentToDelete.type),
|
||||
),
|
||||
})),
|
||||
);
|
||||
setDocumentToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove document:', err);
|
||||
}
|
||||
}, [documentToDelete, removePDF, removeEPUB, removeHTML]);
|
||||
}, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]);
|
||||
|
||||
const handleDragStart = useCallback((doc: DocumentListDocument) => {
|
||||
if (!doc.folderId) {
|
||||
setDraggedDoc(doc);
|
||||
}
|
||||
}, []);
|
||||
const handleDropOnFolder = useCallback(
|
||||
(folderId: string, item: DocumentDragItem) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => {
|
||||
if (f.id !== folderId) {
|
||||
// Remove the dropped docs from any other folder they were in.
|
||||
return {
|
||||
...f,
|
||||
documents: f.documents.filter(
|
||||
(d) => !item.ids.includes(d.id),
|
||||
),
|
||||
};
|
||||
}
|
||||
const existingIds = new Set(f.documents.map((d) => d.id));
|
||||
const newDocs = item.docs
|
||||
.filter((d) => !existingIds.has(d.id))
|
||||
.map((d) => ({ ...d, folderId }));
|
||||
return { ...f, documents: [...f.documents, ...newDocs] };
|
||||
}),
|
||||
);
|
||||
selection.clear();
|
||||
},
|
||||
[selection],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedDoc(null);
|
||||
setDropTargetDoc(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent, doc: DocumentListDocument) => {
|
||||
e.preventDefault();
|
||||
if (draggedDoc && draggedDoc.id !== doc.id && !draggedDoc.folderId) {
|
||||
// Only highlight target if neither document is in a folder
|
||||
if (!doc.folderId) {
|
||||
setDropTargetDoc(doc);
|
||||
}
|
||||
}
|
||||
}, [draggedDoc]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDropTargetDoc(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent, targetDoc: DocumentListDocument) => {
|
||||
e.preventDefault();
|
||||
console.log('Dropped', draggedDoc?.name, 'on', targetDoc.name);
|
||||
|
||||
if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return;
|
||||
|
||||
// If target doc is unfoldered, create a new folder
|
||||
if (!targetDoc.folderId) {
|
||||
setPendingFolderDocs({
|
||||
source: draggedDoc,
|
||||
target: targetDoc
|
||||
});
|
||||
const handleMergeIntoFolder = useCallback(
|
||||
(sources: DocumentListDocument[], target: DocumentListDocument) => {
|
||||
if (target.folderId) return;
|
||||
const filtered = sources.filter((s) => s.id !== target.id && !s.folderId);
|
||||
if (filtered.length === 0) return;
|
||||
setPendingMerge({ sources: filtered, target });
|
||||
setNewFolderName('');
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setDraggedDoc(null);
|
||||
setDropTargetDoc(null);
|
||||
}, [draggedDoc]);
|
||||
const createFolderFromPending = useCallback(() => {
|
||||
if (!pendingMerge) return;
|
||||
const name =
|
||||
newFolderName.trim() ||
|
||||
generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
|
||||
const folderId = `folder-${Date.now()}`;
|
||||
setFolders((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: folderId,
|
||||
name,
|
||||
documents: [
|
||||
...pendingMerge.sources.map((d) => ({ ...d, folderId })),
|
||||
{ ...pendingMerge.target, folderId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
setPendingMerge(null);
|
||||
setNewFolderName('');
|
||||
setShowHint(false);
|
||||
selection.clear();
|
||||
}, [pendingMerge, newFolderName, selection]);
|
||||
|
||||
const handleFolderDrop = useCallback((e: DragEvent, folderId: string) => {
|
||||
e.preventDefault();
|
||||
if (!draggedDoc || draggedDoc.folderId) return;
|
||||
const createManualFolder = useCallback(() => {
|
||||
const name = newFolderName.trim() || `New Folder`;
|
||||
const folderId = `folder-${Date.now()}`;
|
||||
setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
|
||||
setNewFolderName('');
|
||||
setManualFolderPrompt(false);
|
||||
}, [newFolderName]);
|
||||
|
||||
// Add document to existing folder
|
||||
setFolders(folders.map(f => {
|
||||
if (f.id === folderId && !f.documents.some(d => d.id === draggedDoc.id)) {
|
||||
return {
|
||||
...f,
|
||||
documents: [...f.documents, { ...draggedDoc, folderId }]
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
|
||||
setDraggedDoc(null);
|
||||
setDropTargetDoc(null);
|
||||
}, [draggedDoc, folders]);
|
||||
const handleDeleteFolder = useCallback((folderId: string) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
|
||||
}, [sidebarFilter]);
|
||||
|
||||
const toggleFolderCollapse = useCallback((folderId: string) => {
|
||||
setCollapsedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) {
|
||||
next.delete(folderId);
|
||||
} else {
|
||||
next.add(folderId);
|
||||
}
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createFolder = useCallback(() => {
|
||||
if (!pendingFolderDocs) return;
|
||||
// Status bar summary.
|
||||
const summary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (counts.pdf) parts.push(`${counts.pdf} PDF${counts.pdf === 1 ? '' : 's'}`);
|
||||
if (counts.epub) parts.push(`${counts.epub} EPUB${counts.epub === 1 ? '' : 's'}`);
|
||||
if (counts.html) parts.push(`${counts.html} HTML${counts.html === 1 ? '' : 's'}`);
|
||||
return parts.join(' • ');
|
||||
}, [counts]);
|
||||
|
||||
const folderName = newFolderName.trim() ||
|
||||
generateDefaultFolderName(pendingFolderDocs.source, pendingFolderDocs.target);
|
||||
|
||||
const folderId = `folder-${Date.now()}`;
|
||||
setFolders(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: folderId,
|
||||
name: folderName,
|
||||
documents: [
|
||||
{ ...pendingFolderDocs.source, folderId },
|
||||
{ ...pendingFolderDocs.target, folderId }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
setPendingFolderDocs(null);
|
||||
setNewFolderName('');
|
||||
setDropTargetDoc(null);
|
||||
setDraggedDoc(null);
|
||||
setShowHint(false);
|
||||
}, [pendingFolderDocs, newFolderName]);
|
||||
|
||||
const handleFolderNameKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createFolder();
|
||||
} else if (e.key === 'Escape') {
|
||||
setPendingFolderDocs(null);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}, [createFolder]);
|
||||
const totalBytes = useMemo(
|
||||
() => allDocuments.reduce((acc, d) => acc + d.size, 0),
|
||||
[allDocuments],
|
||||
);
|
||||
|
||||
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
|
||||
return <DocumentListSkeleton viewMode={viewMode} />;
|
||||
return <DocumentListSkeleton viewMode={viewMode === 'list' ? 'list' : 'grid'} />;
|
||||
}
|
||||
|
||||
if (allDocuments.length === 0) {
|
||||
return <div className="w-full text-center text-muted">No documents uploaded yet</div>;
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto py-12">
|
||||
<DocumentUploader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unfolderedDocuments = allDocuments.filter(
|
||||
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
|
||||
);
|
||||
|
||||
// Build compact summary (counts per type + total size)
|
||||
const summaryParts: string[] = [];
|
||||
if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`);
|
||||
if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`);
|
||||
if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`);
|
||||
const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2);
|
||||
const fallbackViewMode: ViewMode =
|
||||
viewMode === 'columns' && isNarrow ? 'list' : viewMode;
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="w-full mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
|
||||
<SortControls
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortByChange={setSortBy}
|
||||
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted mb-2" data-doc-summary>
|
||||
{summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total
|
||||
</p>
|
||||
<div className="mb-3">
|
||||
<DocumentUploader variant="compact" />
|
||||
</div>
|
||||
|
||||
{showHint && allDocuments.length > 1 && (
|
||||
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm mb-2">
|
||||
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
||||
<Button
|
||||
<FinderWindow
|
||||
toolbar={
|
||||
<FinderToolbar
|
||||
viewMode={fallbackViewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
iconSize={iconSize}
|
||||
onIconSizeChange={setIconSize}
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortByChange={setSortBy}
|
||||
onSortDirectionToggle={() =>
|
||||
setSortDirection((p) => (p === 'asc' ? 'desc' : 'asc'))
|
||||
}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onNewFolder={() => {
|
||||
setNewFolderName('');
|
||||
setManualFolderPrompt(true);
|
||||
}}
|
||||
onToggleSidebar={() => setSidebarOpen((p) => !p)}
|
||||
isNarrow={isNarrow}
|
||||
leftSlot={brand}
|
||||
rightSlot={appActions}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<FinderSidebar
|
||||
filter={sidebarFilter}
|
||||
onFilterChange={setSidebarFilter}
|
||||
folders={folders}
|
||||
counts={counts}
|
||||
onDropOnFolder={handleDropOnFolder}
|
||||
width={sidebarWidth}
|
||||
onWidthChange={setSidebarWidth}
|
||||
topSlot={<DocumentUploader variant="compact" />}
|
||||
/>
|
||||
}
|
||||
statusBar={
|
||||
<FinderStatusBar
|
||||
itemCount={allDocuments.length}
|
||||
selectedCount={selection.selectionSize}
|
||||
totalSize={totalBytes}
|
||||
summary={summary}
|
||||
/>
|
||||
}
|
||||
sidebarOpen={sidebarOpen}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
>
|
||||
{showHint && allDocuments.length > 1 && (
|
||||
<div className="px-3 pt-3 shrink-0 bg-background">
|
||||
<div className="flex items-center justify-between bg-base border border-offbase rounded-md px-3 py-1 text-[12px]">
|
||||
<p className="text-foreground">
|
||||
Drag files onto each other to make folders. Drop into the sidebar to move.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(false)}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'h-7 w-7 hover:bg-base',
|
||||
})}
|
||||
className="h-6 w-6 inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base hover:scale-[1.02] rounded transition-all duration-200 ease-out"
|
||||
aria-label="Dismiss hint"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
viewMode === 'grid'
|
||||
? 'grid w-full grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5'
|
||||
: 'w-full space-y-1'
|
||||
}
|
||||
>
|
||||
|
||||
{folders.map(folder => (
|
||||
<DocumentFolder
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
isCollapsed={collapsedFolders.has(folder.id)}
|
||||
onToggleCollapse={toggleFolderCollapse}
|
||||
onDelete={() => setFolders(prev => prev.filter(f => f.id !== folder.id))}
|
||||
sortedDocuments={sortDocuments(folder.documents)}
|
||||
onDocumentDelete={setDocumentToDelete}
|
||||
draggedDoc={draggedDoc}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleFolderDrop}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sortDocuments(unfolderedDocuments).map(doc => (
|
||||
<DocumentListItem
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
onDelete={setDocumentToDelete}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
isDropTarget={dropTargetDoc?.id === doc.id}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateFolderDialog
|
||||
isOpen={pendingFolderDocs !== null}
|
||||
onClose={() => setPendingFolderDocs(null)}
|
||||
folderName={newFolderName}
|
||||
onFolderNameChange={setNewFolderName}
|
||||
onKeyDown={handleFolderNameKeyDown}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={documentToDelete !== null}
|
||||
onClose={() => setDocumentToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Document"
|
||||
message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`}
|
||||
confirmText="Delete"
|
||||
isDangerous={true}
|
||||
/>
|
||||
</div>
|
||||
</DndProvider>
|
||||
<CreateFolderDialog
|
||||
isOpen={pendingMerge !== null}
|
||||
onClose={() => setPendingMerge(null)}
|
||||
folderName={newFolderName}
|
||||
onFolderNameChange={setNewFolderName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createFolderFromPending();
|
||||
} else if (e.key === 'Escape') {
|
||||
setPendingMerge(null);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CreateFolderDialog
|
||||
isOpen={manualFolderPrompt}
|
||||
onClose={() => setManualFolderPrompt(false)}
|
||||
folderName={newFolderName}
|
||||
onFolderNameChange={setNewFolderName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createManualFolder();
|
||||
} else if (e.key === 'Escape') {
|
||||
setManualFolderPrompt(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={documentToDelete !== null}
|
||||
onClose={() => setDocumentToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Document"
|
||||
message={`Are you sure you want to delete ${documentToDelete?.name ?? 'this document'}?`}
|
||||
confirmText="Delete"
|
||||
isDangerous
|
||||
/>
|
||||
</FinderWindow>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentList({
|
||||
brand,
|
||||
appActions,
|
||||
}: {
|
||||
brand?: ReactNode;
|
||||
appActions?: ReactNode;
|
||||
} = {}) {
|
||||
return (
|
||||
<DocumentDndProvider>
|
||||
<DocumentSelectionProvider>
|
||||
<DocumentListInner brand={brand} appActions={appActions} />
|
||||
</DocumentSelectionProvider>
|
||||
</DocumentDndProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import { DragEvent, useState } from 'react';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { DocumentListDocument } from '@/types/documents';
|
||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { buttonClass } from '@/components/formPrimitives';
|
||||
|
||||
interface DocumentListItemProps {
|
||||
doc: DocumentListDocument;
|
||||
onDelete: (doc: DocumentListDocument) => void;
|
||||
dragEnabled?: boolean;
|
||||
onDragStart?: (doc: DocumentListDocument) => void;
|
||||
onDragEnd?: () => void;
|
||||
onDragOver?: (e: DragEvent, doc: DocumentListDocument) => void;
|
||||
onDragLeave?: () => void;
|
||||
onDrop?: (e: DragEvent, doc: DocumentListDocument) => void;
|
||||
isDropTarget?: boolean;
|
||||
viewMode: 'list' | 'grid';
|
||||
}
|
||||
|
||||
export function DocumentListItem({
|
||||
doc,
|
||||
onDelete,
|
||||
dragEnabled = true,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
isDropTarget = false,
|
||||
viewMode,
|
||||
}: DocumentListItemProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { data: session } = useAuthSession();
|
||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||
|
||||
// Only allow drag and drop interactions for documents not in folders
|
||||
const isDraggable = dragEnabled && !doc.folderId;
|
||||
const allowDropTarget = !doc.folderId;
|
||||
const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous);
|
||||
const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed');
|
||||
|
||||
const handleDocumentClick = () => {
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={isDraggable}
|
||||
onDragStart={() => onDragStart?.(doc)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
||||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||
aria-busy={loading}
|
||||
className={
|
||||
viewMode === 'grid'
|
||||
? `
|
||||
flex w-full min-w-0 flex-col
|
||||
group border border-offbase rounded-md overflow-hidden
|
||||
transition-colors duration-150 relative bg-base hover:bg-offbase
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||
${loading ? 'prism-outline' : ''}
|
||||
`
|
||||
: `
|
||||
w-full group
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||
border border-offbase rounded-md p-1
|
||||
transition-colors duration-150 relative
|
||||
`
|
||||
}
|
||||
>
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<Link
|
||||
href={href}
|
||||
draggable={false}
|
||||
className="block"
|
||||
aria-label="Open document preview"
|
||||
onClick={handleDocumentClick}
|
||||
>
|
||||
<DocumentPreview doc={doc} />
|
||||
</Link>
|
||||
<div className="flex items-center w-full px-1.5 py-1.5">
|
||||
<Link
|
||||
href={href}
|
||||
draggable={false}
|
||||
className="document-link flex items-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
|
||||
onClick={handleDocumentClick}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
||||
) : doc.type === 'epub' ? (
|
||||
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
||||
) : (
|
||||
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
|
||||
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">
|
||||
{doc.name}
|
||||
</p>
|
||||
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
onClick={() => onDelete(doc)}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'ml-1 h-7 w-7 text-muted hover:bg-offbase',
|
||||
})}
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center w-full">
|
||||
<Link
|
||||
href={href}
|
||||
draggable={false}
|
||||
className="document-link flex items-center align-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
|
||||
onClick={handleDocumentClick}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
||||
) : doc.type === 'epub' ? (
|
||||
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
||||
) : (
|
||||
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
|
||||
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">
|
||||
{doc.name}
|
||||
</p>
|
||||
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
onClick={() => onDelete(doc)}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'ml-1 h-7 w-7 text-muted hover:bg-offbase',
|
||||
})}
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, ListIcon, GridIcon } from '@/components/icons/Icons';
|
||||
import { SortBy, SortDirection } from '@/types/documents';
|
||||
|
||||
interface SortControlsProps {
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
onSortByChange: (value: SortBy) => void;
|
||||
onSortDirectionChange: () => void;
|
||||
viewMode: 'list' | 'grid';
|
||||
onViewModeChange: (mode: 'list' | 'grid') => void;
|
||||
}
|
||||
|
||||
export function SortControls({
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSortByChange,
|
||||
onSortDirectionChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: SortControlsProps) {
|
||||
const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [
|
||||
{ value: 'name', label: 'Name', up: 'A-Z', down: 'Z-A' },
|
||||
{ value: 'type', label: 'Type', up: 'A-Z', down: 'Z-A' },
|
||||
{ value: 'date', label: 'Date', up: 'Newest', down: 'Oldest' },
|
||||
{ value: 'size', label: 'Size' , up: 'Smallest', down: 'Largest' },
|
||||
];
|
||||
|
||||
const currentSort = sortOptions.find(opt => opt.value === sortBy);
|
||||
const directionLabel = sortDirection === 'asc' ? currentSort?.up : currentSort?.down;
|
||||
|
||||
const buttonBaseClass = "h-6 flex items-center justify-center bg-base hover:bg-offbase rounded border border-transparent hover:border-offbase text-xs sm:text-sm transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent";
|
||||
const activeIconClass = "text-accent";
|
||||
const inactiveIconClass = "text-muted";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="hidden xs:flex items-center bg-base rounded p-[1px] gap-0.5 border border-transparent">
|
||||
<Button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'list' ? activeIconClass : inactiveIconClass}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
<ListIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'grid' ? activeIconClass : inactiveIconClass}`}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<GridIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden xs:block h-4 w-px bg-offbase mx-1" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={onSortDirectionChange}
|
||||
className={`${buttonBaseClass} px-2 text-xs`}
|
||||
>
|
||||
{directionLabel}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<ListboxButton className={`${buttonBaseClass} pl-2 pr-1 gap-1 min-w-[80px] justify-between focus:outline-none focus:ring-accent focus:ring-2`}>
|
||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3 opacity-50" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions anchor="top end" className="absolute z-50 w-32 overflow-auto rounded-lg bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none p-1">
|
||||
{sortOptions.map((option) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-1.5 px-2 rounded text-xs ${active ? 'bg-offbase text-accent' : 'text-foreground'} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/components/doclist/dnd/DocumentDndProvider.tsx
Normal file
60
src/components/doclist/dnd/DocumentDndProvider.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { TouchBackend } from 'react-dnd-touch-backend';
|
||||
|
||||
const TOUCH_QUERY = '(hover: none) and (pointer: coarse)';
|
||||
|
||||
function detectTouchInitial(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.matchMedia(TOUCH_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DnD provider that swaps between HTML5 (mouse/keyboard) and Touch backends
|
||||
* based on the primary input device. Long-press activates a drag on touch.
|
||||
*
|
||||
* react-dnd doesn't allow swapping backends after mount, so the subtree is
|
||||
* remounted with a `key` when the media query changes. That happens at most
|
||||
* when the user docks/undocks a tablet — fine in practice.
|
||||
*/
|
||||
export function DocumentDndProvider({ children }: { children: ReactNode }) {
|
||||
const [isTouch, setIsTouch] = useState<boolean>(detectTouchInitial);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia(TOUCH_QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => setIsTouch(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
if (isTouch) {
|
||||
return (
|
||||
<DndProvider
|
||||
key="touch"
|
||||
backend={TouchBackend}
|
||||
options={{
|
||||
enableMouseEvents: false,
|
||||
enableTouchEvents: true,
|
||||
delayTouchStart: 220,
|
||||
ignoreContextMenu: true,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider key="html5" backend={HTML5Backend}>
|
||||
{children}
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
123
src/components/doclist/dnd/DocumentSelectionContext.tsx
Normal file
123
src/components/doclist/dnd/DocumentSelectionContext.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { DocumentListDocument } from '@/types/documents';
|
||||
|
||||
type DocKey = string; // `${type}-${id}`
|
||||
|
||||
const docKey = (doc: Pick<DocumentListDocument, 'id' | 'type'>): DocKey =>
|
||||
`${doc.type}-${doc.id}`;
|
||||
|
||||
interface SelectionContextValue {
|
||||
selection: ReadonlySet<DocKey>;
|
||||
isSelected: (doc: Pick<DocumentListDocument, 'id' | 'type'>) => boolean;
|
||||
selectionSize: number;
|
||||
/** Treat the visible-doc order so shift-click range-select can resolve. */
|
||||
setVisibleOrder: (docs: DocumentListDocument[]) => void;
|
||||
/** Click semantics: with shift = range-select, with meta/ctrl = toggle, plain = single-select. */
|
||||
select: (
|
||||
doc: DocumentListDocument,
|
||||
opts?: { shift?: boolean; meta?: boolean },
|
||||
) => void;
|
||||
clear: () => void;
|
||||
/** Force a precise selection (e.g. on drag start when nothing was selected). */
|
||||
replace: (docs: DocumentListDocument[]) => void;
|
||||
/** Resolve concrete docs for the current selection from the visible-order. */
|
||||
getSelectedDocs: () => DocumentListDocument[];
|
||||
}
|
||||
|
||||
const SelectionContext = createContext<SelectionContextValue | null>(null);
|
||||
|
||||
export function DocumentSelectionProvider({ children }: { children: ReactNode }) {
|
||||
const [selection, setSelection] = useState<Set<DocKey>>(() => new Set());
|
||||
const [order, setOrder] = useState<DocumentListDocument[]>([]);
|
||||
const [anchor, setAnchor] = useState<DocKey | null>(null);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(doc: Pick<DocumentListDocument, 'id' | 'type'>) => selection.has(docKey(doc)),
|
||||
[selection],
|
||||
);
|
||||
|
||||
const setVisibleOrder = useCallback((docs: DocumentListDocument[]) => {
|
||||
setOrder(docs);
|
||||
}, []);
|
||||
|
||||
const select = useCallback<SelectionContextValue['select']>(
|
||||
(doc, opts) => {
|
||||
const key = docKey(doc);
|
||||
if (opts?.shift && anchor && order.length > 0) {
|
||||
const anchorIdx = order.findIndex((d) => docKey(d) === anchor);
|
||||
const targetIdx = order.findIndex((d) => docKey(d) === key);
|
||||
if (anchorIdx >= 0 && targetIdx >= 0) {
|
||||
const [lo, hi] =
|
||||
anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];
|
||||
const next = new Set<DocKey>();
|
||||
for (let i = lo; i <= hi; i++) next.add(docKey(order[i]));
|
||||
setSelection(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (opts?.meta) {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
setAnchor(key);
|
||||
return;
|
||||
}
|
||||
setSelection(new Set([key]));
|
||||
setAnchor(key);
|
||||
},
|
||||
[anchor, order],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSelection(new Set());
|
||||
setAnchor(null);
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((docs: DocumentListDocument[]) => {
|
||||
const next = new Set<DocKey>();
|
||||
for (const d of docs) next.add(docKey(d));
|
||||
setSelection(next);
|
||||
setAnchor(docs[0] ? docKey(docs[0]) : null);
|
||||
}, []);
|
||||
|
||||
const getSelectedDocs = useCallback(() => {
|
||||
if (selection.size === 0 || order.length === 0) return [];
|
||||
return order.filter((d) => selection.has(docKey(d)));
|
||||
}, [selection, order]);
|
||||
|
||||
const value = useMemo<SelectionContextValue>(
|
||||
() => ({
|
||||
selection,
|
||||
isSelected,
|
||||
selectionSize: selection.size,
|
||||
setVisibleOrder,
|
||||
select,
|
||||
clear,
|
||||
replace,
|
||||
getSelectedDocs,
|
||||
}),
|
||||
[selection, isSelected, setVisibleOrder, select, clear, replace, getSelectedDocs],
|
||||
);
|
||||
|
||||
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDocumentSelection(): SelectionContextValue {
|
||||
const ctx = useContext(SelectionContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useDocumentSelection must be used inside DocumentSelectionProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
18
src/components/doclist/dnd/dndTypes.ts
Normal file
18
src/components/doclist/dnd/dndTypes.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { DocumentListDocument } from '@/types/documents';
|
||||
|
||||
export const DND_DOCUMENT = 'openreader/document' as const;
|
||||
|
||||
export interface DocumentDragItem {
|
||||
/** Doc ids being dragged together (may be a single id). */
|
||||
ids: string[];
|
||||
/** Concrete doc records for the dragged ids — used for previews and folder hints. */
|
||||
docs: DocumentListDocument[];
|
||||
/** Folder id the drag originated from, if any (cross-folder vs unfoldered moves). */
|
||||
fromFolderId?: string;
|
||||
}
|
||||
|
||||
export type DropTarget =
|
||||
| { kind: 'document'; id: string }
|
||||
| { kind: 'folder'; id: string }
|
||||
| { kind: 'sidebar-folder'; id: string }
|
||||
| { kind: 'pane'; id: 'unfoldered' };
|
||||
241
src/components/doclist/views/ColumnsView.tsx
Normal file
241
src/components/doclist/views/ColumnsView.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import type { DocumentListDocument, Folder } from '@/types/documents';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { FolderIcon, ChevronRightSmall } from '../window/finderIcons';
|
||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
interface ColumnsViewProps {
|
||||
folders: Folder[];
|
||||
unfolderedDocs: DocumentListDocument[];
|
||||
onDeleteDoc: (doc: DocumentListDocument) => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}
|
||||
|
||||
type Selected =
|
||||
| { kind: 'folder'; id: string }
|
||||
| { kind: 'doc'; doc: DocumentListDocument }
|
||||
| null;
|
||||
|
||||
function KindIcon({ doc }: { doc: DocumentListDocument }) {
|
||||
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 text-red-500" />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 text-blue-500" />;
|
||||
return <FileIcon className="w-4 h-4 text-muted" />;
|
||||
}
|
||||
|
||||
function ColumnDocRow({
|
||||
doc,
|
||||
active,
|
||||
onClick,
|
||||
onMergeIntoFolder,
|
||||
}: {
|
||||
doc: DocumentListDocument;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}) {
|
||||
const selection = useDocumentSelection();
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
|
||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||
type: DND_DOCUMENT,
|
||||
item: () => {
|
||||
const sel = selection.getSelectedDocs();
|
||||
const dragging = isSelected && sel.length > 1 ? sel : [doc];
|
||||
if (!isSelected) selection.replace([doc]);
|
||||
return { ids: dragging.map((d) => d.id), docs: dragging, fromFolderId: doc.folderId };
|
||||
},
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
}), [doc, isSelected]);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => !isInFolder && !item.ids.includes(doc.id),
|
||||
drop: (item) => onMergeIntoFolder(item.docs, doc),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [doc, isInFolder, onMergeIntoFolder]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
dragRef(node);
|
||||
dropRef(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
data-doc-tile
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-[12px] transition-colors duration-200 ease-out cursor-pointer ' +
|
||||
(active || isSelected
|
||||
? 'bg-accent text-background'
|
||||
: 'text-foreground hover:bg-offbase') +
|
||||
(isOver && canDrop ? ' ring-1 ring-accent' : '') +
|
||||
(isDragging ? ' opacity-50' : '')
|
||||
}
|
||||
>
|
||||
<KindIcon doc={doc} />
|
||||
<span className="truncate flex-1">{doc.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnFolderRow({
|
||||
folder,
|
||||
active,
|
||||
onClick,
|
||||
onDropOnFolder,
|
||||
}: {
|
||||
folder: Folder;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
}) {
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id),
|
||||
drop: (item) => onDropOnFolder(folder.id, item),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [folder.id, onDropOnFolder]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-[12px] transition-colors duration-200 ease-out cursor-pointer ' +
|
||||
(active
|
||||
? 'bg-accent text-background'
|
||||
: 'text-foreground hover:bg-offbase') +
|
||||
(isOver && canDrop ? ' ring-1 ring-accent' : '')
|
||||
}
|
||||
>
|
||||
<FolderIcon className={'w-4 h-4 ' + (active ? 'text-background' : 'text-accent')} />
|
||||
<span className="truncate flex-1">{folder.name}</span>
|
||||
<span className={'text-[10px] ' + (active ? 'text-background' : 'text-muted')}>
|
||||
{folder.documents.length}
|
||||
</span>
|
||||
<ChevronRightSmall className={'w-3 h-3 ' + (active ? 'text-background' : 'text-muted')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ children, title }: { children: React.ReactNode; title?: string }) {
|
||||
return (
|
||||
<div className="w-[260px] shrink-0 h-full bg-base border-r border-offbase overflow-y-auto">
|
||||
{title && (
|
||||
<div className="sticky top-0 px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted bg-base border-b border-offbase">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1.5 flex flex-col gap-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColumnsView({
|
||||
folders,
|
||||
unfolderedDocs,
|
||||
onDeleteDoc,
|
||||
onDropOnFolder,
|
||||
onMergeIntoFolder,
|
||||
}: ColumnsViewProps) {
|
||||
const { setVisibleOrder } = useDocumentSelection();
|
||||
const [selected, setSelected] = useState<Selected>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const all = [...folders.flatMap((f) => f.documents), ...unfolderedDocs];
|
||||
setVisibleOrder(all);
|
||||
}, [folders, unfolderedDocs, setVisibleOrder]);
|
||||
|
||||
// Drop selection if it no longer exists.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
if (selected.kind === 'folder' && !folders.find((f) => f.id === selected.id)) {
|
||||
setSelected(null);
|
||||
}
|
||||
}, [folders, selected]);
|
||||
|
||||
const selectedFolder =
|
||||
selected?.kind === 'folder' ? folders.find((f) => f.id === selected.id) : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex overflow-x-auto overflow-y-hidden">
|
||||
<Column title="Library">
|
||||
{folders.map((f) => (
|
||||
<ColumnFolderRow
|
||||
key={f.id}
|
||||
folder={f}
|
||||
active={selected?.kind === 'folder' && selected.id === f.id}
|
||||
onClick={() => setSelected({ kind: 'folder', id: f.id })}
|
||||
onDropOnFolder={onDropOnFolder}
|
||||
/>
|
||||
))}
|
||||
{unfolderedDocs.map((d) => (
|
||||
<ColumnDocRow
|
||||
key={`${d.type}-${d.id}`}
|
||||
doc={d}
|
||||
active={selected?.kind === 'doc' && selected.doc.id === d.id}
|
||||
onClick={() => setSelected({ kind: 'doc', doc: d })}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
{selectedFolder && (
|
||||
<Column title={selectedFolder.name}>
|
||||
{selectedFolder.documents.length === 0 && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted text-center">Empty folder</p>
|
||||
)}
|
||||
{selectedFolder.documents.map((d) => (
|
||||
<ColumnDocRow
|
||||
key={`${d.type}-${d.id}`}
|
||||
doc={d}
|
||||
active={selected?.kind === 'doc' && selected.doc.id === d.id}
|
||||
onClick={() => setSelected({ kind: 'doc', doc: d })}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</Column>
|
||||
)}
|
||||
|
||||
{selected?.kind === 'doc' && (
|
||||
<div className="flex-1 min-w-[280px] h-full bg-background overflow-y-auto p-4">
|
||||
<div className="max-w-[360px] mx-auto">
|
||||
<div className="rounded-lg overflow-hidden border border-offbase">
|
||||
<DocumentPreview doc={selected.doc} />
|
||||
</div>
|
||||
<h3 className="mt-3 text-[13px] font-semibold text-foreground truncate">
|
||||
{selected.doc.name}
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
{selected.doc.type.toUpperCase()} • {(selected.doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
href={`/${selected.doc.type}/${encodeURIComponent(selected.doc.id)}`}
|
||||
className="flex-1 inline-flex items-center justify-center h-8 rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.02] transition-all duration-200 ease-out"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(selected.doc)}
|
||||
className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.03] transition-all duration-200 ease-out"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/components/doclist/views/DocumentTile.tsx
Normal file
198
src/components/doclist/views/DocumentTile.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
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';
|
||||
|
||||
interface DocumentTileProps {
|
||||
doc: DocumentListDocument;
|
||||
iconSize: IconSize;
|
||||
onDelete: (doc: DocumentListDocument) => void;
|
||||
/** Fired when two unfoldered docs are dropped together → caller should open a "create folder" dialog. */
|
||||
onMergeIntoFolder: (source: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}
|
||||
|
||||
const SIZE_LABEL: Record<IconSize, string> = { sm: 'S', md: 'M', lg: 'L', xl: 'XL' };
|
||||
void SIZE_LABEL;
|
||||
|
||||
export function DocumentTile({
|
||||
doc,
|
||||
iconSize,
|
||||
onDelete,
|
||||
onMergeIntoFolder,
|
||||
}: DocumentTileProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { data: session } = useAuthSession();
|
||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||
const selection = useDocumentSelection();
|
||||
|
||||
const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous);
|
||||
const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed');
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
|
||||
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
||||
DocumentDragItem,
|
||||
void,
|
||||
{ isDragging: boolean }
|
||||
>(() => {
|
||||
return {
|
||||
type: DND_DOCUMENT,
|
||||
item: () => {
|
||||
// If the dragged doc is selected and there are multiple selected, drag the group.
|
||||
const selected = selection.getSelectedDocs();
|
||||
const dragging = isSelected && selected.length > 1
|
||||
? selected
|
||||
: [doc];
|
||||
// Reflect the actual drag in the selection so visuals match.
|
||||
if (!isSelected) selection.replace([doc]);
|
||||
return {
|
||||
ids: dragging.map((d) => d.id),
|
||||
docs: dragging,
|
||||
fromFolderId: doc.folderId,
|
||||
};
|
||||
},
|
||||
collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }),
|
||||
};
|
||||
}, [doc, isSelected]);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<
|
||||
DocumentDragItem,
|
||||
void,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => {
|
||||
// Only allow drop-to-merge on unfoldered docs, and don't drop a doc on itself.
|
||||
if (isInFolder) return false;
|
||||
return !item.ids.includes(doc.id);
|
||||
},
|
||||
drop: (item) => onMergeIntoFolder(item.docs, doc),
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}), [doc, isInFolder, onMergeIntoFolder]);
|
||||
|
||||
const isDropTarget = isOver && canDrop;
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
dragRef(node);
|
||||
dropRef(node);
|
||||
previewRef(node);
|
||||
};
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
||||
return;
|
||||
}
|
||||
selection.replace([doc]);
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
// Cap any stuck loading flag if the user navigates back.
|
||||
useEffect(() => {
|
||||
if (!loading) return;
|
||||
const t = setTimeout(() => setLoading(false), 2500);
|
||||
return () => clearTimeout(t);
|
||||
}, [loading]);
|
||||
|
||||
const sizeClasses: Record<IconSize, string> = {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-[12px]',
|
||||
lg: 'text-[13px]',
|
||||
xl: 'text-[14px]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
data-doc-tile
|
||||
aria-selected={isSelected}
|
||||
className={
|
||||
'group relative flex flex-col rounded-md overflow-hidden border transition-all duration-200 ease-out hover:scale-[1.01] ' +
|
||||
(isSelected
|
||||
? 'border-accent bg-offbase'
|
||||
: 'border-offbase bg-base hover:bg-offbase hover:border-accent') +
|
||||
(isDropTarget ? ' ring-1 ring-accent' : '') +
|
||||
(isDragging ? ' opacity-50' : '') +
|
||||
(loading ? ' prism-outline' : '')
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
draggable={false}
|
||||
className="block"
|
||||
aria-label={`Open ${doc.name}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<DocumentPreview doc={doc} />
|
||||
</Link>
|
||||
<div className="flex items-center w-full px-1.5 py-1.5">
|
||||
<Link
|
||||
href={href}
|
||||
draggable={false}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon className="w-4 h-4 text-red-500" />
|
||||
) : doc.type === 'epub' ? (
|
||||
<EPUBIcon className="w-4 h-4 text-blue-500" />
|
||||
) : (
|
||||
<FileIcon className="w-4 h-4 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>
|
||||
</Link>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
onClick={() => onDelete(doc)}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'ml-1 h-6 w-6 text-muted hover:bg-base',
|
||||
})}
|
||||
aria-label={`Delete ${doc.name}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
src/components/doclist/views/GalleryView.tsx
Normal file
232
src/components/doclist/views/GalleryView.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import type { DocumentListDocument, Folder } from '@/types/documents';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { FolderIcon } from '../window/finderIcons';
|
||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
interface GalleryViewProps {
|
||||
folders: Folder[];
|
||||
unfolderedDocs: DocumentListDocument[];
|
||||
onDeleteDoc: (doc: DocumentListDocument) => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}
|
||||
|
||||
function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) {
|
||||
if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-red-500'} />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />;
|
||||
return <FileIcon className={className ?? 'w-4 h-4 text-muted'} />;
|
||||
}
|
||||
|
||||
function GalleryThumb({
|
||||
doc,
|
||||
active,
|
||||
onClick,
|
||||
onMergeIntoFolder,
|
||||
}: {
|
||||
doc: DocumentListDocument;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}) {
|
||||
const selection = useDocumentSelection();
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
|
||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||
type: DND_DOCUMENT,
|
||||
item: () => {
|
||||
const sel = selection.getSelectedDocs();
|
||||
const dragging = isSelected && sel.length > 1 ? sel : [doc];
|
||||
if (!isSelected) selection.replace([doc]);
|
||||
return { ids: dragging.map((d) => d.id), docs: dragging, fromFolderId: doc.folderId };
|
||||
},
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
}), [doc, isSelected]);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => !isInFolder && !item.ids.includes(doc.id),
|
||||
drop: (item) => onMergeIntoFolder(item.docs, doc),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [doc, isInFolder, onMergeIntoFolder]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
dragRef(node);
|
||||
dropRef(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
data-doc-tile
|
||||
onClick={onClick}
|
||||
className={
|
||||
'shrink-0 cursor-pointer rounded-md overflow-hidden border transition-all duration-200 ease-out snap-start ' +
|
||||
(active
|
||||
? 'border-accent ring-1 ring-accent w-[110px]'
|
||||
: 'border-offbase hover:border-accent hover:scale-[1.01] w-[88px]') +
|
||||
(isOver && canDrop ? ' ring-1 ring-accent' : '') +
|
||||
(isDragging ? ' opacity-50' : '')
|
||||
}
|
||||
title={doc.name}
|
||||
>
|
||||
<div className="aspect-[3/4] bg-base">
|
||||
<DocumentPreview doc={doc} />
|
||||
</div>
|
||||
<div className="px-1.5 py-1 flex items-center gap-1 bg-base">
|
||||
<KindIcon doc={doc} className="w-3 h-3 shrink-0 text-muted" />
|
||||
<span className="truncate text-[10px] text-foreground">{doc.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryFolderThumb({
|
||||
folder,
|
||||
active,
|
||||
onClick,
|
||||
onDropOnFolder,
|
||||
}: {
|
||||
folder: Folder;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
}) {
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id),
|
||||
drop: (item) => onDropOnFolder(folder.id, item),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [folder.id, onDropOnFolder]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'shrink-0 cursor-pointer rounded-md overflow-hidden border transition-all duration-200 ease-out snap-start flex flex-col items-center justify-center gap-2 ' +
|
||||
(active
|
||||
? 'border-accent ring-1 ring-accent w-[110px]'
|
||||
: 'border-offbase hover:border-accent hover:scale-[1.01] w-[88px]') +
|
||||
(isOver && canDrop ? ' ring-1 ring-accent' : '')
|
||||
}
|
||||
>
|
||||
<div className="aspect-[3/4] w-full bg-base flex items-center justify-center">
|
||||
<FolderIcon className="w-10 h-10 text-accent" />
|
||||
</div>
|
||||
<div className="px-1.5 py-1 w-full text-center bg-base">
|
||||
<span className="text-[10px] text-foreground truncate block">{folder.name}</span>
|
||||
<span className="text-[9px] text-muted">{folder.documents.length} items</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryView({
|
||||
folders,
|
||||
unfolderedDocs,
|
||||
onDeleteDoc,
|
||||
onDropOnFolder,
|
||||
onMergeIntoFolder,
|
||||
}: GalleryViewProps) {
|
||||
const { setVisibleOrder } = useDocumentSelection();
|
||||
const allDocs = useMemo(
|
||||
() => [...folders.flatMap((f) => f.documents), ...unfolderedDocs],
|
||||
[folders, unfolderedDocs],
|
||||
);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleOrder(allDocs);
|
||||
}, [allDocs, setVisibleOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIdx >= allDocs.length) setActiveIdx(Math.max(0, allDocs.length - 1));
|
||||
}, [allDocs.length, activeIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.closest('input, textarea, [contenteditable]')) return;
|
||||
if (e.key === 'ArrowRight') {
|
||||
setActiveIdx((i) => Math.min(allDocs.length - 1, i + 1));
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
setActiveIdx((i) => Math.max(0, i - 1));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [allDocs.length]);
|
||||
|
||||
const activeDoc = allDocs[activeIdx];
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background">
|
||||
{activeDoc ? (
|
||||
<div className="flex flex-col items-center gap-3 max-w-[420px]">
|
||||
<div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg">
|
||||
<DocumentPreview doc={activeDoc} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
|
||||
{activeDoc.name}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted">
|
||||
{activeDoc.type.toUpperCase()} •{' '}
|
||||
{(activeDoc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/${activeDoc.type}/${encodeURIComponent(activeDoc.id)}`}
|
||||
className="h-8 px-4 inline-flex items-center justify-center rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.02] transition-all duration-200 ease-out"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(activeDoc)}
|
||||
className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.02] transition-all duration-200 ease-out"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-muted">No documents to show</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-offbase bg-base">
|
||||
<div className="flex gap-2 overflow-x-auto p-2 snap-x snap-mandatory">
|
||||
{folders.map((f) => (
|
||||
<GalleryFolderThumb
|
||||
key={f.id}
|
||||
folder={f}
|
||||
active={false}
|
||||
onClick={() => { /* could expand later */ }}
|
||||
onDropOnFolder={onDropOnFolder}
|
||||
/>
|
||||
))}
|
||||
{allDocs.map((doc, i) => (
|
||||
<GalleryThumb
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
active={i === activeIdx}
|
||||
onClick={() => setActiveIdx(i)}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/components/doclist/views/IconsView.tsx
Normal file
195
src/components/doclist/views/IconsView.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import type {
|
||||
DocumentListDocument,
|
||||
Folder,
|
||||
IconSize,
|
||||
} from '@/types/documents';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentTile } from './DocumentTile';
|
||||
import { FolderIcon } from '../window/finderIcons';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
interface IconsViewProps {
|
||||
folders: Folder[];
|
||||
unfolderedDocs: DocumentListDocument[];
|
||||
iconSize: IconSize;
|
||||
collapsedFolders: Set<string>;
|
||||
onToggleCollapse: (folderId: string) => void;
|
||||
onDeleteFolder: (folderId: string) => void;
|
||||
onDeleteDoc: (doc: DocumentListDocument) => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}
|
||||
|
||||
const COLS: Record<IconSize, string> = {
|
||||
sm: 'grid-cols-3 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-8',
|
||||
md: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6',
|
||||
lg: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
|
||||
xl: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
function FolderGridRow({
|
||||
folder,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
onDelete,
|
||||
iconSize,
|
||||
onDropOnFolder,
|
||||
onDeleteDoc,
|
||||
onMergeIntoFolder,
|
||||
}: {
|
||||
folder: Folder;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onDelete: () => void;
|
||||
iconSize: IconSize;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
onDeleteDoc: (doc: DocumentListDocument) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}) {
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<
|
||||
DocumentDragItem,
|
||||
void,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id),
|
||||
drop: (item) => onDropOnFolder(folder.id, item),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [folder.id, onDropOnFolder]);
|
||||
|
||||
const totalSize = folder.documents.reduce((acc, d) => acc + d.size, 0);
|
||||
const isTarget = isOver && canDrop;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
||||
className={
|
||||
'col-span-full rounded-md border border-offbase overflow-hidden bg-base ' +
|
||||
(isTarget ? 'ring-1 ring-accent' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1 bg-offbase border-b border-offbase">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className="flex items-center gap-1.5 text-[12px] font-semibold text-foreground hover:text-accent transition-colors duration-200 ease-out"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<FolderIcon className="w-3.5 h-3.5 text-accent" />
|
||||
{folder.name}
|
||||
<span className="text-[10px] font-normal text-muted ml-1">
|
||||
{folder.documents.length} • {(totalSize / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${collapsed ? '-rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-muted hover:text-accent hover:bg-base hover:scale-[1.02] rounded-md transition-all duration-200 ease-out"
|
||||
aria-label={`Delete ${folder.name}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" 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>
|
||||
<Transition
|
||||
show={!collapsed}
|
||||
enter="transition-all duration-200 ease-out"
|
||||
enterFrom="opacity-0 max-h-0"
|
||||
enterTo="opacity-100 max-h-[2000px]"
|
||||
leave="transition-all duration-150 ease-in"
|
||||
leaveFrom="opacity-100 max-h-[2000px]"
|
||||
leaveTo="opacity-0 max-h-0"
|
||||
>
|
||||
<div className={`grid gap-2 sm:gap-3 p-2 ${COLS[iconSize]}`}>
|
||||
{folder.documents.map((doc) => (
|
||||
<DocumentTile
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
iconSize={iconSize}
|
||||
onDelete={onDeleteDoc}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconsView({
|
||||
folders,
|
||||
unfolderedDocs,
|
||||
iconSize,
|
||||
collapsedFolders,
|
||||
onToggleCollapse,
|
||||
onDeleteFolder,
|
||||
onDeleteDoc,
|
||||
onDropOnFolder,
|
||||
onMergeIntoFolder,
|
||||
}: IconsViewProps) {
|
||||
const { setVisibleOrder, clear } = useDocumentSelection();
|
||||
|
||||
useEffect(() => {
|
||||
const all: DocumentListDocument[] = [
|
||||
...folders.flatMap((f) => f.documents),
|
||||
...unfolderedDocs,
|
||||
];
|
||||
setVisibleOrder(all);
|
||||
}, [folders, unfolderedDocs, setVisibleOrder]);
|
||||
|
||||
const handleBackgroundClick: React.MouseEventHandler = (e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-doc-tile]')) return;
|
||||
clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleBackgroundClick}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-3"
|
||||
>
|
||||
<div className={`grid gap-2 sm:gap-3 ${COLS[iconSize]}`}>
|
||||
{folders.map((folder) => (
|
||||
<FolderGridRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
collapsed={collapsedFolders.has(folder.id)}
|
||||
onToggleCollapse={() => onToggleCollapse(folder.id)}
|
||||
onDelete={() => onDeleteFolder(folder.id)}
|
||||
iconSize={iconSize}
|
||||
onDropOnFolder={onDropOnFolder}
|
||||
onDeleteDoc={onDeleteDoc}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
{unfolderedDocs.map((doc) => (
|
||||
<DocumentTile
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
iconSize={iconSize}
|
||||
onDelete={onDeleteDoc}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
src/components/doclist/views/ListView.tsx
Normal file
357
src/components/doclist/views/ListView.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { Button, Transition } from '@headlessui/react';
|
||||
import type {
|
||||
DocumentListDocument,
|
||||
Folder,
|
||||
SortBy,
|
||||
SortDirection,
|
||||
} from '@/types/documents';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { FolderIcon } from '../window/finderIcons';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
interface ListViewProps {
|
||||
folders: Folder[];
|
||||
unfolderedDocs: DocumentListDocument[];
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (sortBy: SortBy, direction: SortDirection) => void;
|
||||
collapsedFolders: Set<string>;
|
||||
onToggleCollapse: (folderId: string) => void;
|
||||
onDeleteFolder: (folderId: string) => void;
|
||||
onDeleteDoc: (doc: DocumentListDocument) => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return mb >= 1 ? `${mb.toFixed(2)} MB` : `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
function KindIcon({ doc }: { doc: DocumentListDocument }) {
|
||||
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 text-red-500" />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 text-blue-500" />;
|
||||
return <FileIcon className="w-4 h-4 text-muted" />;
|
||||
}
|
||||
|
||||
function HeaderCell({
|
||||
label,
|
||||
field,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
className,
|
||||
align = 'left',
|
||||
}: {
|
||||
label: string;
|
||||
field: SortBy;
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (b: SortBy, d: SortDirection) => void;
|
||||
className?: string;
|
||||
align?: 'left' | 'right';
|
||||
}) {
|
||||
const active = sortBy === field;
|
||||
const arrow = active ? (sortDirection === 'asc' ? '↑' : '↓') : '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextDir: SortDirection =
|
||||
active && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
onSortChange(field, nextDir);
|
||||
}}
|
||||
className={
|
||||
'flex items-center gap-1 px-2 py-1.5 text-[10px] uppercase tracking-wide font-semibold transition-colors duration-200 ease-out hover:text-accent ' +
|
||||
(active ? 'text-accent' : 'text-muted') +
|
||||
(align === 'right' ? ' justify-end' : '') +
|
||||
' ' +
|
||||
(className ?? '')
|
||||
}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="w-2 text-[10px]">{arrow}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DocRow({
|
||||
doc,
|
||||
isFirstColumnIndented,
|
||||
onDeleteDoc,
|
||||
onMergeIntoFolder,
|
||||
}: {
|
||||
doc: DocumentListDocument;
|
||||
isFirstColumnIndented?: boolean;
|
||||
onDeleteDoc: (d: DocumentListDocument) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}) {
|
||||
const selection = useDocumentSelection();
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||
|
||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||
type: DND_DOCUMENT,
|
||||
item: () => {
|
||||
const selected = selection.getSelectedDocs();
|
||||
const dragging = isSelected && selected.length > 1 ? selected : [doc];
|
||||
if (!isSelected) selection.replace([doc]);
|
||||
return {
|
||||
ids: dragging.map((d) => d.id),
|
||||
docs: dragging,
|
||||
fromFolderId: doc.folderId,
|
||||
};
|
||||
},
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
}), [doc, isSelected]);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => !isInFolder && !item.ids.includes(doc.id),
|
||||
drop: (item) => onMergeIntoFolder(item.docs, doc),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [doc, isInFolder, onMergeIntoFolder]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
dragRef(node);
|
||||
dropRef(node);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) return;
|
||||
const t = setTimeout(() => setLoading(false), 2500);
|
||||
return () => clearTimeout(t);
|
||||
}, [loading]);
|
||||
|
||||
const isTarget = isOver && canDrop;
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
||||
return;
|
||||
}
|
||||
selection.replace([doc]);
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
data-doc-tile
|
||||
aria-selected={isSelected}
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px] items-center text-[12px] border-b border-offbase transition-colors duration-200 ease-out ' +
|
||||
(isSelected
|
||||
? 'bg-offbase text-accent'
|
||||
: 'text-foreground hover:bg-offbase') +
|
||||
(isTarget ? ' ring-1 ring-accent ring-inset' : '') +
|
||||
(isDragging ? ' opacity-50' : '') +
|
||||
(loading ? ' prism-outline' : '')
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
draggable={false}
|
||||
onClick={handleClick}
|
||||
className={
|
||||
'flex items-center gap-2 min-w-0 px-2 py-1.5 ' +
|
||||
(isFirstColumnIndented ? 'pl-8' : '')
|
||||
}
|
||||
>
|
||||
<KindIcon doc={doc} />
|
||||
<span className="truncate">{doc.name}</span>
|
||||
</Link>
|
||||
<span className="px-2 text-[11px] text-muted uppercase tracking-wide">{doc.type}</span>
|
||||
<span className="px-2 text-[11px] text-muted text-right tabular-nums">
|
||||
{formatSize(doc.size)}
|
||||
</span>
|
||||
<span className="px-2 text-[11px] text-muted tabular-nums">
|
||||
{formatDate(doc.lastModified)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDeleteDoc(doc);
|
||||
}}
|
||||
className="h-7 w-7 flex items-center justify-center text-muted hover:text-accent hover:bg-offbase hover:scale-[1.02] rounded transition-all duration-200 ease-out"
|
||||
aria-label={`Delete ${doc.name}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRowGroup({
|
||||
folder,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
onDelete,
|
||||
onDeleteDoc,
|
||||
onDropOnFolder,
|
||||
onMergeIntoFolder,
|
||||
}: {
|
||||
folder: Folder;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onDelete: () => void;
|
||||
onDeleteDoc: (d: DocumentListDocument) => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
|
||||
}) {
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
canDrop: (item) => item.docs.some((d) => d.folderId !== folder.id),
|
||||
drop: (item) => onDropOnFolder(folder.id, item),
|
||||
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
|
||||
}), [folder.id, onDropOnFolder]);
|
||||
|
||||
const isTarget = isOver && canDrop;
|
||||
const totalSize = folder.documents.reduce((acc, d) => acc + d.size, 0);
|
||||
|
||||
return (
|
||||
<div ref={dropRef as unknown as React.RefObject<HTMLDivElement>}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px] items-center text-[12px] border-b border-offbase bg-base ' +
|
||||
(isTarget ? 'ring-1 ring-accent ring-inset' : '')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className="flex items-center gap-1.5 min-w-0 px-2 py-1.5 text-left font-semibold hover:text-accent transition-colors duration-200 ease-out"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 text-muted transition-transform ${collapsed ? '-rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<FolderIcon className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</button>
|
||||
<span className="px-2 text-[11px] text-muted">Folder</span>
|
||||
<span className="px-2 text-[11px] text-muted text-right tabular-nums">
|
||||
{formatSize(totalSize)}
|
||||
</span>
|
||||
<span className="px-2 text-[11px] text-muted tabular-nums">
|
||||
{folder.documents.length} items
|
||||
</span>
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
className="h-7 w-7 flex items-center justify-center text-muted hover:text-accent hover:bg-offbase hover:scale-[1.02] rounded transition-all duration-200 ease-out"
|
||||
aria-label={`Delete ${folder.name}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" 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>
|
||||
<Transition
|
||||
show={!collapsed}
|
||||
enter="transition-all duration-200"
|
||||
enterFrom="opacity-0 max-h-0"
|
||||
enterTo="opacity-100 max-h-[2000px]"
|
||||
leave="transition-all duration-150"
|
||||
leaveFrom="opacity-100 max-h-[2000px]"
|
||||
leaveTo="opacity-0 max-h-0"
|
||||
>
|
||||
<div>
|
||||
{folder.documents.map((doc) => (
|
||||
<DocRow
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
isFirstColumnIndented
|
||||
onDeleteDoc={onDeleteDoc}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
folders,
|
||||
unfolderedDocs,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
collapsedFolders,
|
||||
onToggleCollapse,
|
||||
onDeleteFolder,
|
||||
onDeleteDoc,
|
||||
onDropOnFolder,
|
||||
onMergeIntoFolder,
|
||||
}: ListViewProps) {
|
||||
const { setVisibleOrder, clear } = useDocumentSelection();
|
||||
|
||||
useEffect(() => {
|
||||
const all = [...folders.flatMap((f) => f.documents), ...unfolderedDocs];
|
||||
setVisibleOrder(all);
|
||||
}, [folders, unfolderedDocs, setVisibleOrder]);
|
||||
|
||||
const handleBackgroundClick: React.MouseEventHandler = (e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-doc-tile]')) return;
|
||||
clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={handleBackgroundClick} className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px]">
|
||||
<HeaderCell label="Name" field="name" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
|
||||
<HeaderCell label="Kind" field="type" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
|
||||
<HeaderCell label="Size" field="size" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} align="right" />
|
||||
<HeaderCell label="Modified" field="date" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
|
||||
<span />
|
||||
</div>
|
||||
<div>
|
||||
{folders.map((folder) => (
|
||||
<FolderRowGroup
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
collapsed={collapsedFolders.has(folder.id)}
|
||||
onToggleCollapse={() => onToggleCollapse(folder.id)}
|
||||
onDelete={() => onDeleteFolder(folder.id)}
|
||||
onDeleteDoc={onDeleteDoc}
|
||||
onDropOnFolder={onDropOnFolder}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
{unfolderedDocs.map((doc) => (
|
||||
<DocRow
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
doc={doc}
|
||||
onDeleteDoc={onDeleteDoc}
|
||||
onMergeIntoFolder={onMergeIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/doclist/window/FinderSidebar.tsx
Normal file
237
src/components/doclist/window/FinderSidebar.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
'use client';
|
||||
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import type { Folder, SidebarFilter } from '@/types/documents';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { FolderIcon, HomeIcon, ClockIcon } from './finderIcons';
|
||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
interface FinderSidebarProps {
|
||||
filter: SidebarFilter;
|
||||
onFilterChange: (filter: SidebarFilter) => void;
|
||||
folders: Folder[];
|
||||
counts: { all: number; pdf: number; epub: number; html: number };
|
||||
/** When dragging onto a folder row, move dropped docs into that folder. */
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
/** Width controls (desktop only). */
|
||||
width: number;
|
||||
onWidthChange: (px: number) => void;
|
||||
topSlot?: ReactNode;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 168;
|
||||
const MAX_WIDTH = 320;
|
||||
|
||||
interface SidebarRowProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
count?: number;
|
||||
trailing?: ReactNode;
|
||||
isDropTarget?: boolean;
|
||||
}
|
||||
|
||||
function SidebarRow({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
trailing,
|
||||
isDropTarget,
|
||||
}: SidebarRowProps) {
|
||||
return (
|
||||
<button
|
||||
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] ' +
|
||||
(active
|
||||
? 'border-accent bg-offbase text-accent'
|
||||
: 'border-transparent text-foreground hover:border-accent hover:bg-offbase hover:text-accent') +
|
||||
(isDropTarget ? ' ring-1 ring-accent' : '')
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-200 ' +
|
||||
(active ? 'text-accent' : 'text-muted group-hover:text-accent')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate flex-1">{label}</span>
|
||||
{typeof count === 'number' && count > 0 && (
|
||||
<span className="text-[10px] text-muted tabular-nums">{count}</span>
|
||||
)}
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({
|
||||
folder,
|
||||
active,
|
||||
onClick,
|
||||
onDropOnFolder,
|
||||
}: {
|
||||
folder: Folder;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||
}) {
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<
|
||||
DocumentDragItem,
|
||||
void,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>(() => ({
|
||||
accept: DND_DOCUMENT,
|
||||
drop: (item) => {
|
||||
onDropOnFolder(folder.id, item);
|
||||
},
|
||||
canDrop: (item) => {
|
||||
// Don't accept if all items are already in this folder.
|
||||
return item.docs.some((d) => d.folderId !== folder.id);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}), [folder.id, onDropOnFolder]);
|
||||
|
||||
const isDropTarget = isOver && canDrop;
|
||||
return (
|
||||
<div ref={dropRef as unknown as React.RefObject<HTMLDivElement>}>
|
||||
<SidebarRow
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
icon={<FolderIcon className="w-3.5 h-3.5" />}
|
||||
label={folder.name}
|
||||
count={folder.documents.length}
|
||||
isDropTarget={isDropTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children, isFirst }: { children: ReactNode; isFirst?: boolean }) {
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold ' +
|
||||
(isFirst ? 'pt-1.5' : 'pt-3')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinderSidebar({
|
||||
filter,
|
||||
onFilterChange,
|
||||
folders,
|
||||
counts,
|
||||
onDropOnFolder,
|
||||
width,
|
||||
onWidthChange,
|
||||
topSlot,
|
||||
}: FinderSidebarProps) {
|
||||
const startRef = useRef<{ x: number; w: number } | null>(null);
|
||||
|
||||
const onResizeStart = (e: React.PointerEvent) => {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
startRef.current = { x: e.clientX, w: width };
|
||||
};
|
||||
const onResizeMove = (e: React.PointerEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const delta = e.clientX - startRef.current.x;
|
||||
const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startRef.current.w + delta));
|
||||
onWidthChange(next);
|
||||
};
|
||||
const onResizeEnd = (e: React.PointerEvent) => {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
startRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{ width }}
|
||||
className="relative h-full bg-base border-r border-offbase shrink-0 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2 flex flex-col gap-0.5">
|
||||
{topSlot && (
|
||||
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{topSlot}
|
||||
</div>
|
||||
)}
|
||||
<SectionLabel isFirst={!!topSlot}>Library</SectionLabel>
|
||||
<SidebarRow
|
||||
active={filter === 'all'}
|
||||
onClick={() => onFilterChange('all')}
|
||||
icon={<HomeIcon className="w-3.5 h-3.5" />}
|
||||
label="All Documents"
|
||||
count={counts.all}
|
||||
/>
|
||||
<SidebarRow
|
||||
active={filter === 'recents'}
|
||||
onClick={() => onFilterChange('recents')}
|
||||
icon={<ClockIcon className="w-3.5 h-3.5" />}
|
||||
label="Recents"
|
||||
/>
|
||||
|
||||
<SectionLabel>Kinds</SectionLabel>
|
||||
<SidebarRow
|
||||
active={filter === 'pdf'}
|
||||
onClick={() => onFilterChange('pdf')}
|
||||
icon={<PDFIcon className="w-3.5 h-3.5" />}
|
||||
label="PDF"
|
||||
count={counts.pdf}
|
||||
/>
|
||||
<SidebarRow
|
||||
active={filter === 'epub'}
|
||||
onClick={() => onFilterChange('epub')}
|
||||
icon={<EPUBIcon className="w-3.5 h-3.5" />}
|
||||
label="EPUB"
|
||||
count={counts.epub}
|
||||
/>
|
||||
<SidebarRow
|
||||
active={filter === 'html'}
|
||||
onClick={() => onFilterChange('html')}
|
||||
icon={<FileIcon className="w-3.5 h-3.5" />}
|
||||
label="HTML / Text"
|
||||
count={counts.html}
|
||||
/>
|
||||
|
||||
{folders.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>Folders</SectionLabel>
|
||||
{folders.map((folder) => (
|
||||
<FolderRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
active={filter === `folder:${folder.id}`}
|
||||
onClick={() => onFilterChange(`folder:${folder.id}`)}
|
||||
onDropOnFolder={onDropOnFolder}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
onPointerDown={onResizeStart}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeEnd}
|
||||
onPointerCancel={onResizeEnd}
|
||||
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-offbase active:bg-accent transition-colors duration-200 ease-out"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/components/doclist/window/FinderStatusBar.tsx
Normal file
38
src/components/doclist/window/FinderStatusBar.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use client';
|
||||
|
||||
interface FinderStatusBarProps {
|
||||
itemCount: number;
|
||||
selectedCount: number;
|
||||
totalSize: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function FinderStatusBar({
|
||||
itemCount,
|
||||
selectedCount,
|
||||
totalSize,
|
||||
summary,
|
||||
}: FinderStatusBarProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="h-6 px-3 flex items-center justify-between gap-3 text-[11px] text-muted bg-base border-t border-offbase select-none"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<span className="shrink-0">
|
||||
{selectedCount > 0
|
||||
? `${selectedCount} of ${itemCount} selected`
|
||||
: `${itemCount} item${itemCount === 1 ? '' : 's'}`}
|
||||
<span className="mx-1.5 text-muted">•</span>
|
||||
{formatSize(totalSize)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
src/components/doclist/window/FinderToolbar.tsx
Normal file
230
src/components/doclist/window/FinderToolbar.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
'use client';
|
||||
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
||||
import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents';
|
||||
import {
|
||||
IconsViewIcon,
|
||||
ListViewIcon,
|
||||
ColumnsViewIcon,
|
||||
GalleryViewIcon,
|
||||
SearchIcon,
|
||||
FolderPlusIcon,
|
||||
HamburgerIcon,
|
||||
} from './finderIcons';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FinderToolbarProps {
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
iconSize: IconSize;
|
||||
onIconSizeChange: (size: IconSize) => void;
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
onSortByChange: (s: SortBy) => void;
|
||||
onSortDirectionToggle: () => void;
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
onNewFolder: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
/** True when the columns view should be disabled (mobile viewport). */
|
||||
isNarrow: boolean;
|
||||
/** App-level content rendered at the far left (brand/logo). */
|
||||
leftSlot?: ReactNode;
|
||||
/** App-level content rendered at the far right (settings, user menu). */
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
const VIEW_BUTTONS: Array<{ value: ViewMode; label: string; Icon: typeof IconsViewIcon }> = [
|
||||
{ value: 'icons', label: 'Icons', Icon: IconsViewIcon },
|
||||
{ value: 'list', label: 'List', Icon: ListViewIcon },
|
||||
{ value: 'columns', label: 'Columns', Icon: ColumnsViewIcon },
|
||||
{ value: 'gallery', label: 'Gallery', Icon: GalleryViewIcon },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortBy; label: string; asc: string; desc: string }> = [
|
||||
{ value: 'name', label: 'Name', asc: 'A → Z', desc: 'Z → A' },
|
||||
{ value: 'type', label: 'Kind', asc: 'A → Z', desc: 'Z → A' },
|
||||
{ value: 'date', label: 'Modified', asc: 'Oldest', desc: 'Newest' },
|
||||
{ value: 'size', label: 'Size', asc: 'Smallest', desc: 'Largest' },
|
||||
];
|
||||
|
||||
const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
|
||||
{ value: 'sm', label: 'S' },
|
||||
{ value: 'md', label: 'M' },
|
||||
{ value: 'lg', label: 'L' },
|
||||
{ value: 'xl', label: 'XL' },
|
||||
];
|
||||
|
||||
// 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]';
|
||||
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
|
||||
// matches the height of a standalone TOOLBAR_BTN.
|
||||
const PILL = 'inline-flex items-center rounded-md border border-offbase bg-base p-0.5 gap-0.5 shrink-0';
|
||||
const PILL_SEGMENT =
|
||||
'inline-flex items-center justify-center rounded-[5px] text-xs transition-colors duration-200 ease-out';
|
||||
const PILL_SEGMENT_INACTIVE = 'text-muted hover:bg-offbase hover:text-accent';
|
||||
const PILL_SEGMENT_ACTIVE = 'bg-offbase text-accent';
|
||||
|
||||
export function FinderToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
iconSize,
|
||||
onIconSizeChange,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSortByChange,
|
||||
onSortDirectionToggle,
|
||||
query,
|
||||
onQueryChange,
|
||||
onNewFolder,
|
||||
onToggleSidebar,
|
||||
isNarrow,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
}: FinderToolbarProps) {
|
||||
const currentSort = SORT_OPTIONS.find((o) => o.value === sortBy) ?? SORT_OPTIONS[0];
|
||||
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
|
||||
|
||||
return (
|
||||
<div className="min-h-10 px-2 sm:px-3 py-1 flex items-center gap-1.5 sm:gap-2 bg-base border-b border-offbase">
|
||||
{leftSlot && (
|
||||
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase">
|
||||
{leftSlot}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} shrink-0`}
|
||||
aria-label="Toggle sidebar"
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<HamburgerIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className={PILL}>
|
||||
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
|
||||
const disabled = value === 'columns' && isNarrow;
|
||||
const active = viewMode === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onViewModeChange(value)}
|
||||
aria-pressed={active}
|
||||
aria-label={`${label} view`}
|
||||
title={disabled ? `${label} (desktop only)` : `${label} view`}
|
||||
className={
|
||||
PILL_SEGMENT +
|
||||
' h-6 w-7 ' +
|
||||
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE) +
|
||||
(disabled ? ' opacity-40 cursor-not-allowed hover:bg-transparent hover:text-muted' : '')
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{viewMode === 'icons' && (
|
||||
<div className={`${PILL} hidden sm:inline-flex`}>
|
||||
{ICON_SIZES.map(({ value, label }) => {
|
||||
const active = iconSize === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onIconSizeChange(value)}
|
||||
aria-pressed={active}
|
||||
aria-label={`Icon size ${label}`}
|
||||
className={
|
||||
PILL_SEGMENT +
|
||||
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
|
||||
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSortDirectionToggle}
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
|
||||
title="Toggle sort direction"
|
||||
>
|
||||
{directionLabel}
|
||||
</button>
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<ListboxButton
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
|
||||
>
|
||||
<span>{currentSort.label}</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
anchor="bottom end"
|
||||
className="z-50 mt-1 rounded-md bg-background border border-offbase shadow-lg p-1 focus:outline-none"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<ListboxOption
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className={({ active, selected }) =>
|
||||
`cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${
|
||||
active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||
} ${selected ? 'font-semibold' : ''}`
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]">
|
||||
<SearchIcon className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewFolder}
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 shrink-0`}
|
||||
title="New folder"
|
||||
>
|
||||
<FolderPlusIcon className="w-4 h-4" />
|
||||
<span className="hidden md:inline">New Folder</span>
|
||||
</button>
|
||||
|
||||
{rightSlot && (
|
||||
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
|
||||
{rightSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/doclist/window/FinderWindow.tsx
Normal file
100
src/components/doclist/window/FinderWindow.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use client';
|
||||
|
||||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface FinderWindowProps {
|
||||
toolbar: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
statusBar: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Controlled sidebar open/closed state (drives mobile drawer + desktop collapse). */
|
||||
sidebarOpen: boolean;
|
||||
onSidebarOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const NARROW_QUERY = '(max-width: 767px)';
|
||||
|
||||
export function useIsNarrow(): boolean {
|
||||
const [isNarrow, setIsNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia(NARROW_QUERY);
|
||||
const update = () => setIsNarrow(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
return isNarrow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finder-style file pane: sidebar + toolbar + content + status bar.
|
||||
* No separate window chrome — it sits flush under the existing app header.
|
||||
*/
|
||||
export function FinderWindow({
|
||||
toolbar,
|
||||
sidebar,
|
||||
statusBar,
|
||||
children,
|
||||
sidebarOpen,
|
||||
onSidebarOpenChange,
|
||||
}: FinderWindowProps) {
|
||||
const isNarrow = useIsNarrow();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-background overflow-hidden">
|
||||
{toolbar}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{!isNarrow && sidebarOpen && (
|
||||
<div className="h-full">{sidebar}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusBar}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
|
||||
<Dialog
|
||||
onClose={() => onSidebarOpenChange(false)}
|
||||
className="relative z-40 md:hidden"
|
||||
>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transition-opacity duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
<div className="fixed inset-y-0 left-0 flex max-w-full">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transition-transform duration-200 ease-out"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-150 ease-in"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
<DialogPanel
|
||||
className="w-[80vw] max-w-[280px] h-full bg-base border-r border-offbase shadow-xl"
|
||||
onClick={() => onSidebarOpenChange(false)}
|
||||
>
|
||||
{sidebar}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/doclist/window/finderIcons.tsx
Normal file
107
src/components/doclist/window/finderIcons.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { SVGProps } from 'react';
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
const baseSvg = (props: IconProps) => ({
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.6,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
width: props.width ?? '1em',
|
||||
height: props.height ?? '1em',
|
||||
...props,
|
||||
});
|
||||
|
||||
export const SearchIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FolderIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="M3 7.5C3 6.4 3.9 5.5 5 5.5h3.6c.5 0 1 .2 1.4.6l1.4 1.4c.4.4.9.6 1.4.6H19c1.1 0 2 .9 2 2v7c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FolderPlusIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="M3 7.5C3 6.4 3.9 5.5 5 5.5h3.6c.5 0 1 .2 1.4.6l1.4 1.4c.4.4.9.6 1.4.6H19c1.1 0 2 .9 2 2v7c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7.5Z" />
|
||||
<path d="M12 11v5M9.5 13.5h5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="M9 5v14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconsViewIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ListViewIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ColumnsViewIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<rect x="3" y="4" width="5" height="16" rx="1" />
|
||||
<rect x="10" y="4" width="5" height="16" rx="1" />
|
||||
<rect x="17" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const GalleryViewIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<rect x="3" y="4" width="18" height="11" rx="1.5" />
|
||||
<rect x="4" y="17" width="4" height="3" rx="0.6" />
|
||||
<rect x="10" y="17" width="4" height="3" rx="0.6" />
|
||||
<rect x="16" y="17" width="4" height="3" rx="0.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronRightSmall = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronLeftSmall = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="m15 6-6 6 6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HamburgerIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ClockIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HomeIcon = (props: IconProps) => (
|
||||
<svg {...baseSvg(props)}>
|
||||
<path d="m3 11 9-7 9 7" />
|
||||
<path d="M5 10v9a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -74,28 +74,46 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
|
|||
disabled: isUploading || isConverting
|
||||
});
|
||||
|
||||
const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`;
|
||||
const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3';
|
||||
const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${
|
||||
variant === 'compact' ? 'hover:scale-[1.02]' : ''
|
||||
} ${
|
||||
isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
|
||||
} ${className}`;
|
||||
|
||||
const borderBgClass =
|
||||
variant === 'compact'
|
||||
? `${
|
||||
isDragActive
|
||||
? 'border border-accent bg-offbase text-accent'
|
||||
: 'border border-dashed border-offbase text-foreground hover:border-accent hover:bg-offbase hover:text-accent'
|
||||
}`
|
||||
: `${
|
||||
isDragActive
|
||||
? 'border-2 border-dashed border-accent bg-base text-foreground'
|
||||
: 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.008]'
|
||||
}`;
|
||||
|
||||
const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg';
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`${containerBase} ${paddingClass}`}
|
||||
className={`${containerBase} ${borderBgClass} ${paddingClass}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{variant === 'compact' ? (
|
||||
<div className="flex items-center gap-2 text-left">
|
||||
<UploadIcon className="w-5 h-5 text-muted" />
|
||||
<div className="flex items-center gap-2 text-left w-full min-w-0">
|
||||
<UploadIcon className="w-3.5 h-3.5 text-muted group-hover:text-accent shrink-0 transition-colors duration-200" />
|
||||
{isUploading ? (
|
||||
<p className="text-xs font-medium text-foreground">Uploading…</p>
|
||||
<p className="text-[12px] font-medium truncate flex-1">Uploading…</p>
|
||||
) : isConverting ? (
|
||||
<p className="text-xs font-medium text-foreground">Converting DOCX…</p>
|
||||
<p className="text-[12px] font-medium truncate flex-1">Converting DOCX…</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{isDragActive ? 'Drop files here' : 'Drop files or click'}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<p className="text-[12px] truncate flex-1">
|
||||
{isDragActive ? 'Drop files here' : 'Upload documents'}
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{error && <p className="text-[10px] text-red-500 truncate shrink-0">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,13 +60,24 @@ export interface Folder {
|
|||
export type SortBy = 'name' | 'type' | 'date' | 'size';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export type ViewMode = 'icons' | 'list' | 'columns' | 'gallery';
|
||||
export type IconSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
// Filter applied from the sidebar.
|
||||
// Examples: 'all', 'recents', 'pdf', 'epub', 'html', or `folder:<folderId>`.
|
||||
export type SidebarFilter = string;
|
||||
|
||||
export interface DocumentListState {
|
||||
sortBy: SortBy;
|
||||
sortDirection: SortDirection;
|
||||
folders: Folder[];
|
||||
collapsedFolders: string[];
|
||||
showHint: boolean;
|
||||
viewMode?: 'list' | 'grid';
|
||||
viewMode?: ViewMode | 'list' | 'grid';
|
||||
iconSize?: IconSize;
|
||||
sidebarWidth?: number;
|
||||
sidebarFilter?: SidebarFilter;
|
||||
sidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryDocument extends BaseDocument {
|
||||
|
|
|
|||
Loading…
Reference in a new issue