From 80bc0b000661c7f0599c4b377673a0c5ad5e60f2 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Thu, 13 Feb 2025 04:12:16 -0700 Subject: [PATCH] Refactor new doc list --- src/app/page.tsx | 2 +- src/components/DocumentList.tsx | 523 ------------------ src/components/SettingsModal.tsx | 6 +- src/components/doclist/CreateFolderDialog.tsx | 68 +++ src/components/doclist/DocumentFolder.tsx | 128 +++++ src/components/doclist/DocumentList.tsx | 364 ++++++++++++ src/components/doclist/DocumentListItem.tsx | 71 +++ src/components/doclist/SortControls.tsx | 59 ++ 8 files changed, 694 insertions(+), 527 deletions(-) delete mode 100644 src/components/DocumentList.tsx create mode 100644 src/components/doclist/CreateFolderDialog.tsx create mode 100644 src/components/doclist/DocumentFolder.tsx create mode 100644 src/components/doclist/DocumentList.tsx create mode 100644 src/components/doclist/DocumentListItem.tsx create mode 100644 src/components/doclist/SortControls.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 58789cb..2b74e42 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ import { DocumentUploader } from '@/components/DocumentUploader'; -import { DocumentList } from '@/components/DocumentList'; +import { DocumentList } from '@/components/doclist/DocumentList'; import { SettingsModal } from '@/components/SettingsModal'; export default function Home() { diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx deleted file mode 100644 index b67991b..0000000 --- a/src/components/DocumentList.tsx +++ /dev/null @@ -1,523 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useCallback, useState, useEffect } from 'react'; -import { useDocuments } from '@/contexts/DocumentContext'; -import { PDFIcon, EPUBIcon, ChevronUpDownIcon } from '@/components/icons/Icons'; -import { ConfirmDialog } from '@/components/ConfirmDialog'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import { - Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, - Popover, - PopoverPanel, - Button, -} from '@headlessui/react'; -import { getDocumentListState, saveDocumentListState } from '@/utils/indexedDB'; -import { - DocumentType, - DocumentListDocument, - Folder, - DocumentListState, - SortBy, - SortDirection -} from '@/types/documents'; - -type DocumentToDelete = { - id: string; - name: string; - type: DocumentType; -}; - -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)); - - 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'; - 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() { - const { - pdfDocs, - removePDFDocument: removePDF, - isPDFLoading, - epubDocs, - removeEPUBDocument: removeEPUB, - isEPUBLoading, - } = useDocuments(); - - const [sortBy, setSortBy] = useState('name'); - const [sortDirection, setSortDirection] = useState('desc'); - const [folders, setFolders] = useState([]); - const [documentToDelete, setDocumentToDelete] = useState(null); - const [draggedDoc, setDraggedDoc] = useState(null); - const [dropTargetDoc, setDropTargetDoc] = useState(null); - const [newFolderName, setNewFolderName] = useState(''); - const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null); - const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); - const [isInitialized, setIsInitialized] = useState(false); - const [showHint, setShowHint] = useState(true); - - 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 - } - setIsInitialized(true); - }; - - loadState(); - }, []); - - useEffect(() => { - const saveState = async () => { - const state: DocumentListState = { - sortBy, - sortDirection, - folders, - collapsedFolders: Array.from(collapsedFolders), - showHint - }; - await saveDocumentListState(state); - }; - - if (isInitialized) { // Prevents saving empty state on first render or back navigation - saveState(); - } - }, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]); - - const allDocuments: DocumentListDocument[] = [ - ...pdfDocs.map(doc => ({ - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: 'pdf' as const, - })), - ...epubDocs.map(doc => ({ - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: 'epub' as const, - })), - ]; - - const sortOptions: Array<{ value: SortBy; label: string }> = [ - { value: 'name', label: 'Name' }, - { value: 'type', label: 'Type' }, - { value: 'date', label: 'Date' }, - { value: 'size', label: 'Size' }, - ]; - - 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]); - - const handleDragStart = (doc: DocumentListDocument) => { - // Only allow dragging documents that aren't in folders - if (!doc.folderId) { - setDraggedDoc(doc); - } - }; - - const handleDragEnd = () => { - setDraggedDoc(null); - setDropTargetDoc(null); - }; - - const handleDragOver = (e: React.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); - } - } - }; - - const handleDragLeave = () => { - setDropTargetDoc(null); - }; - - const handleDrop = useCallback((e: React.DragEvent, targetDoc: DocumentListDocument) => { - e.preventDefault(); - if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return; - - // Only create new folders for unfoldered documents - if (!targetDoc.folderId) { - setPendingFolderDocs({ - source: draggedDoc, - target: targetDoc - }); - setNewFolderName(''); - } - }, [draggedDoc]); - - const createFolder = useCallback(() => { - if (!pendingFolderDocs) return; - - 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 = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - createFolder(); - } else if (e.key === 'Escape') { - setPendingFolderDocs(null); - setNewFolderName(''); - } - }; - - const handleDelete = useCallback(async () => { - if (!documentToDelete) return; - - try { - if (documentToDelete.type === 'pdf') { - await removePDF(documentToDelete.id); - } else { - await removeEPUB(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) - ) - }))); - - setDocumentToDelete(null); - } catch (err) { - console.error('Failed to remove document:', err); - } - }, [documentToDelete, removePDF, removeEPUB]); - - const toggleFolderCollapse = (folderId: string) => { - setCollapsedFolders((prev: Set) => { - const next = new Set(prev); - if (next.has(folderId)) { - next.delete(folderId); - } else { - next.add(folderId); - } - return next; - }); - }; - - const renderViewControls = () => ( -
-
- - - {sortOptions.find(opt => opt.value === sortBy)?.label} - - - - {sortOptions.map((option) => ( - - `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : '' - } ${selected ? 'font-medium' : ''}` - } - > - {option.label} - - ))} - - -
- - -
- ); - - const renderDocument = (doc: DocumentListDocument) => ( -
handleDragStart(doc)} - onDragEnd={handleDragEnd} - onDragOver={(e) => handleDragOver(e, doc)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, doc)} - className={` - w-full - ${!doc.folderId && dropTargetDoc?.id === doc.id ? 'ring-2 ring-primary bg-primary/10' : ''} - bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow - `} - > -
- -
- {doc.type === 'pdf' ? ( - - ) : ( - - )} -
-
-

{doc.name}

-

- {(doc.size / 1024 / 1024).toFixed(2)} MB -

-
- - -
-
- ); - - // Add this helper function before renderFolder - const calculateFolderSize = (documents: DocumentListDocument[]) => { - return documents.reduce((total, doc) => total + doc.size, 0); - }; - - const deleteFolder = (folderId: string) => { - setFolders(prev => prev.filter(f => f.id !== folderId)); - }; - - const renderFolder = (folder: Folder) => ( -
{ - e.preventDefault(); - // Only show drop target if dragged doc isn't already in a folder - if (draggedDoc && !draggedDoc.folderId) { - setDropTargetDoc({ ...draggedDoc, folderId: folder.id }); - } - }} - onDragLeave={() => setDropTargetDoc(null)} - onDrop={(e: React.DragEvent) => { - e.preventDefault(); - if (!draggedDoc || draggedDoc.folderId) return; - - // Check if document is already in this folder - if (folder.documents.some(doc => doc.id === draggedDoc.id)) { - return; - } - - setFolders(prev => prev.map(f => { - if (f.id === folder.id) { - return { - ...f, - documents: [...f.documents, { ...draggedDoc, folderId: folder.id }] - }; - } - return f; - })); - setDraggedDoc(null); - setDropTargetDoc(null); - }} - className={`rounded-lg p-2 transition-all bg-offbase shadow-lg transition-shadow ${dropTargetDoc?.folderId === folder.id ? 'ring-2 ring-primary bg-primary/10' : '' - }`} - > -
-
-

{folder.name}

- -
- -
- {!collapsedFolders.has(folder.id) && ( -
- {sortDocuments(folder.documents).map(renderDocument)} -
- )} - {collapsedFolders.has(folder.id) && ( -

- {(calculateFolderSize(folder.documents) / 1024 / 1024).toFixed(2)} MB - {` • ${folder.documents.length} ${folder.documents.length === 1 ? 'file' : 'files'}`} -

- )} -
- ); - - if (isPDFLoading || isEPUBLoading) { - return
Loading documents...
; - } - - if (allDocuments.length === 0) { - return
No documents uploaded yet
; - } - - return ( - -
-
-

Your Documents

- {renderViewControls()} -
- -
- {showHint && allDocuments.length > 1 && ( -
-

Drag files on top of each other to make folders

- -
- )} - - {folders.map(renderFolder)} - -
- {/* Only show documents that don't have a folderId */} - {sortDocuments(allDocuments.filter(doc => !folders.some(folder => - folder.documents.some(d => d.id === doc.id) - ))).map(renderDocument)} -
-
- - - {pendingFolderDocs && ( -
-
- -
-

Create New Folder

-
- setNewFolderName(e.target.value)} - onKeyDown={handleFolderNameKeyDown} - placeholder="Enter folder name" - className="w-full rounded-lg bg-base py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" - autoFocus - /> -

Press Enter to create or Escape to cancel

-
-
-
-
- )} - - - setDocumentToDelete(null)} - onConfirm={handleDelete} - title="Delete Document" - message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`} - confirmText="Delete" - isDangerous={true} - /> -
- - ); -} - -const ChevronIcon = ({ className = "w-5 h-5" }) => ( - - - -); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 7d1400c..0856f4c 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { Fragment, useState, useEffect, useCallback } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input } from '@headlessui/react'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; @@ -180,7 +180,7 @@ export function SettingsModal() {
- setLocalApiKey(e.target.value)} @@ -190,7 +190,7 @@ export function SettingsModal() {
- setLocalBaseUrl(e.target.value)} diff --git a/src/components/doclist/CreateFolderDialog.tsx b/src/components/doclist/CreateFolderDialog.tsx new file mode 100644 index 0000000..fbef2bd --- /dev/null +++ b/src/components/doclist/CreateFolderDialog.tsx @@ -0,0 +1,68 @@ +import { Fragment } from 'react'; +import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react'; + +interface CreateFolderDialogProps { + isOpen: boolean; + folderName: string; + onFolderNameChange: (name: string) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onClose: () => void; +} + +export function CreateFolderDialog({ + isOpen, + folderName, + onFolderNameChange, + onKeyDown, + onClose, +}: CreateFolderDialogProps) { + return ( + + + +
+ + +
+
+ + + + Create New Folder + +
+ 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" + autoFocus + /> +

Press Enter to create or Escape to cancel

+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/doclist/DocumentFolder.tsx b/src/components/doclist/DocumentFolder.tsx new file mode 100644 index 0000000..117b74e --- /dev/null +++ b/src/components/doclist/DocumentFolder.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { Button } 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; + dropTargetDoc: DocumentListDocument | null; + onDragStart: (doc: DocumentListDocument) => void; + onDragEnd: () => void; + onDragOver: (e: React.DragEvent, doc: DocumentListDocument) => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent, doc: DocumentListDocument) => void; +} + +const ChevronIcon = ({ className = "w-5 h-5" }) => ( + + + +); + +const calculateFolderSize = (documents: DocumentListDocument[]) => { + return documents.reduce((total, doc) => total + doc.size, 0); +}; + +export function DocumentFolder({ + folder, + isCollapsed, + onToggleCollapse, + onDelete, + sortedDocuments, + onDocumentDelete, + draggedDoc, + dropTargetDoc, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop, +}: DocumentFolderProps) { + const [isHovering, setIsHovering] = useState(false); + const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + setIsHovering(true); + if (draggedDoc && !draggedDoc.folderId) { + onDragOver(e, { ...draggedDoc, folderId: folder.id }); + } + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsHovering(false); + onDragLeave(); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsHovering(false); + if (!draggedDoc || draggedDoc.folderId) return; + onDrop(e, { ...draggedDoc, folderId: folder.id }); + }} + className={`rounded-lg p-2 transition-all bg-offbase shadow hover:shadow-md ${ + isDropTarget ? 'ring-2 ring-accent bg-primary/10' : '' + }`} + > +
+
+

{folder.name}

+ +
+ +
+ + {!isCollapsed && ( +
+ {sortedDocuments.map(doc => ( + + ))} +
+ )} + + {isCollapsed && ( +

+ {(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB + {` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`} +

+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx new file mode 100644 index 0000000..752adfb --- /dev/null +++ b/src/components/doclist/DocumentList.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useCallback, useState, useEffect } 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 { getDocumentListState, saveDocumentListState } from '@/utils/indexedDB'; +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 { Button } from '@headlessui/react'; + +type DocumentToDelete = { + id: string; + name: string; + type: DocumentType; +}; + +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)); + + 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'; + 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('name'); + const [sortDirection, setSortDirection] = useState('asc'); + const [folders, setFolders] = useState([]); + const [documentToDelete, setDocumentToDelete] = useState(null); + const [draggedDoc, setDraggedDoc] = useState(null); + const [dropTargetDoc, setDropTargetDoc] = useState(null); + const [newFolderName, setNewFolderName] = useState(''); + const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null); + const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); + const [isInitialized, setIsInitialized] = useState(false); + const [showHint, setShowHint] = useState(true); + + const { + pdfDocs, + removePDFDocument: removePDF, + isPDFLoading, + epubDocs, + removeEPUBDocument: removeEPUB, + isEPUBLoading, + } = useDocuments(); + + 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 + } + setIsInitialized(true); + }; + + loadState(); + }, []); + + useEffect(() => { + const saveState = async () => { + const state: DocumentListState = { + sortBy, + sortDirection, + folders, + collapsedFolders: Array.from(collapsedFolders), + showHint + }; + await saveDocumentListState(state); + }; + + if (isInitialized) { // Prevents saving empty state on first render or back navigation + saveState(); + } + }, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]); + + const allDocuments: DocumentListDocument[] = [ + ...pdfDocs.map(doc => ({ + id: doc.id, + name: doc.name, + size: doc.size, + lastModified: doc.lastModified, + type: 'pdf' as const, + })), + ...epubDocs.map(doc => ({ + id: doc.id, + name: doc.name, + size: doc.size, + lastModified: doc.lastModified, + type: 'epub' as const, + })), + ]; + + 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]); + + const handleDelete = useCallback(async () => { + if (!documentToDelete) return; + + try { + if (documentToDelete.type === 'pdf') { + await removePDF(documentToDelete.id); + } else { + await removeEPUB(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) + ) + }))); + + setDocumentToDelete(null); + } catch (err) { + console.error('Failed to remove document:', err); + } + }, [documentToDelete, removePDF, removeEPUB]); + + const handleDragStart = useCallback((doc: DocumentListDocument) => { + if (!doc.folderId) { + setDraggedDoc(doc); + } + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedDoc(null); + setDropTargetDoc(null); + }, []); + + const handleDragOver = useCallback((e: React.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: React.DragEvent, targetDoc: DocumentListDocument) => { + e.preventDefault(); + if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return; + + // If target has a folderId, we're dropping into an existing folder + if (targetDoc.folderId) { + const targetFolder = folders.find(f => f.id === targetDoc.folderId); + if (targetFolder && !targetFolder.documents.some(d => d.id === draggedDoc.id)) { + setFolders(prev => prev.map(f => { + if (f.id === targetDoc.folderId) { + return { + ...f, + documents: [...f.documents, { ...draggedDoc, folderId: f.id }] + }; + } + return f; + })); + } + } else { + // Create new folder when dropping on an unfoldered document + setPendingFolderDocs({ + source: draggedDoc, + target: targetDoc + }); + setNewFolderName(''); + } + + setDraggedDoc(null); + setDropTargetDoc(null); + }, [draggedDoc, folders]); + + const toggleFolderCollapse = useCallback((folderId: string) => { + setCollapsedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); + }, []); + + const createFolder = useCallback(() => { + if (!pendingFolderDocs) return; + + 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: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + createFolder(); + } else if (e.key === 'Escape') { + setPendingFolderDocs(null); + setNewFolderName(''); + } + }, [createFolder]); + + if (isPDFLoading || isEPUBLoading) { + return
Loading documents...
; + } + + if (allDocuments.length === 0) { + return
No documents uploaded yet
; + } + + const unfolderedDocuments = allDocuments.filter( + doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id)) + ); + + return ( + +
+
+

Your Documents

+ setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')} + /> +
+ +
+ {showHint && allDocuments.length > 1 && ( +
+

Drag files on top of each other to make folders

+ +
+ )} + + {folders.map(folder => ( + setFolders(prev => prev.filter(f => f.id !== folder.id))} + sortedDocuments={sortDocuments(folder.documents)} + onDocumentDelete={setDocumentToDelete} + draggedDoc={draggedDoc} + dropTargetDoc={dropTargetDoc} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + /> + ))} + + + {sortDocuments(unfolderedDocuments).map(doc => ( + + ))} +
+ + setPendingFolderDocs(null)} + folderName={newFolderName} + onFolderNameChange={setNewFolderName} + onKeyDown={handleFolderNameKeyDown} + /> + + setDocumentToDelete(null)} + onConfirm={handleDelete} + title="Delete Document" + message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`} + confirmText="Delete" + isDangerous={true} + /> +
+
+ ); +} diff --git a/src/components/doclist/DocumentListItem.tsx b/src/components/doclist/DocumentListItem.tsx new file mode 100644 index 0000000..06f3fb0 --- /dev/null +++ b/src/components/doclist/DocumentListItem.tsx @@ -0,0 +1,71 @@ +import Link from 'next/link'; +import { Button } from '@headlessui/react'; +import { PDFIcon, EPUBIcon } from '@/components/icons/Icons'; +import { DocumentListDocument } from '@/types/documents'; + +interface DocumentListItemProps { + doc: DocumentListDocument; + onDelete: (doc: DocumentListDocument) => void; + dragEnabled?: boolean; + onDragStart?: (doc: DocumentListDocument) => void; + onDragEnd?: () => void; + onDragOver?: (e: React.DragEvent, doc: DocumentListDocument) => void; + onDragLeave?: () => void; + onDrop?: (e: React.DragEvent, doc: DocumentListDocument) => void; + isDropTarget?: boolean; +} + +export function DocumentListItem({ + doc, + onDelete, + dragEnabled = true, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop, + isDropTarget = false, +}: DocumentListItemProps) { + return ( +
onDragStart?.(doc)} + onDragEnd={onDragEnd} + onDragOver={(e) => onDragOver?.(e, doc)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop?.(e, doc)} + className={` + w-full + ${!doc.folderId && isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''} + bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow + `} + > +
+ +
+ {doc.type === 'pdf' ? : } +
+
+

{doc.name}

+

+ {(doc.size / 1024 / 1024).toFixed(2)} MB +

+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/doclist/SortControls.tsx b/src/components/doclist/SortControls.tsx new file mode 100644 index 0000000..7b9cfd5 --- /dev/null +++ b/src/components/doclist/SortControls.tsx @@ -0,0 +1,59 @@ +import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react'; +import { ChevronUpDownIcon } from '@/components/icons/Icons'; +import { SortBy, SortDirection } from '@/types/documents'; + +interface SortControlsProps { + sortBy: SortBy; + sortDirection: SortDirection; + onSortByChange: (value: SortBy) => void; + onSortDirectionChange: () => void; +} + +export function SortControls({ + sortBy, + sortDirection, + onSortByChange, + onSortDirectionChange, +}: 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; + + return ( +
+ +
+ + + {sortOptions.find(opt => opt.value === sortBy)?.label} + + + + {sortOptions.map((option) => ( + + `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}` + } + > + {option.label} + + ))} + + +
+
+ ); +} \ No newline at end of file