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:
Richard R 2026-05-27 18:59:13 -06:00
parent 3db193e012
commit 7a88dabc53
25 changed files with 2748 additions and 777 deletions

View file

@ -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",

View file

@ -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

View file

@ -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>

View file

@ -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={{

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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;
}

View 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' };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);

View file

@ -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>

View file

@ -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 {