feat(ui): implement better document grid with file previews
- Introduces `DocumentPreview` component to display rich previews for documents. - PDFs generate a thumbnail of their first page. - EPUBs extract and display their cover image. - HTML, TXT, and Markdown files show a text snippet. - Refactors document list and folder views to a responsive grid layout. - Expands main content areas to accommodate the new grid. - Updates Dockerfile to use Node.js LTS image. - Updates various development and runtime dependencies.
This commit is contained in:
parent
30ade1d79a
commit
4fee9bd451
10 changed files with 895 additions and 580 deletions
|
|
@ -35,7 +35,7 @@ RUN pnpm build
|
||||||
|
|
||||||
|
|
||||||
# Stage 3: minimal runtime image
|
# Stage 3: minimal runtime image
|
||||||
FROM node:current-alpine AS runner
|
FROM node:lts-alpine AS runner
|
||||||
|
|
||||||
# Add runtime OS dependencies:
|
# Add runtime OS dependencies:
|
||||||
# - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper)
|
# - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper)
|
||||||
|
|
|
||||||
28
package.json
28
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "openreader-webui",
|
"name": "openreader-webui",
|
||||||
"version": "v1.1.0",
|
"version": "v1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3003",
|
"dev": "next dev --turbopack -p 3003",
|
||||||
|
|
@ -13,22 +13,22 @@
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"cmpstr": "^3.0.4",
|
"cmpstr": "^3.0.4",
|
||||||
"compromise": "^14.14.4",
|
"compromise": "^14.14.4",
|
||||||
"core-js": "^3.46.0",
|
"core-js": "^3.47.0",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
"dexie-react-hooks": "^4.2.0",
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lru-cache": "^11.2.2",
|
"lru-cache": "^11.2.4",
|
||||||
"next": "^15.5.6",
|
"next": "^15.5.9",
|
||||||
"openai": "^4.104.0",
|
"openai": "^4.104.0",
|
||||||
"pdfjs-dist": "4.8.69",
|
"pdfjs-dist": "4.8.69",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.3",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.3",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|
@ -38,16 +38,16 @@
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^20.19.24",
|
"@types/node": "^20.19.26",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^15.5.6",
|
"eslint-config-next": "^15.5.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
855
pnpm-lock.yaml
855
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ export default function Home() {
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
||||||
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
|
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
|
||||||
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
|
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
|
||||||
|
|
@ -17,7 +17,7 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex-1 px-4 pb-8 overflow-auto">
|
<section className="flex-1 px-4 pb-8 overflow-auto">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
|
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
|
||||||
<HomeContent />
|
<HomeContent />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ export function HomeContent() {
|
||||||
|
|
||||||
if (totalDocs === 0) {
|
if (totalDocs === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="w-full">
|
||||||
<DocumentUploader className="py-12" />
|
<DocumentUploader className="py-12" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="w-full">
|
||||||
<DocumentList />
|
<DocumentList />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function DocumentFolder({
|
||||||
if (!draggedDoc || draggedDoc.folderId) return;
|
if (!draggedDoc || draggedDoc.folderId) return;
|
||||||
onDrop(e, folder.id);
|
onDrop(e, folder.id);
|
||||||
}}
|
}}
|
||||||
className={`w-full overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
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='flex flex-row justify-between p-0'>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|
@ -104,7 +104,14 @@ export function DocumentFolder({
|
||||||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
||||||
>
|
>
|
||||||
<div id={`folder-panel-${folder.id}`} className={`${viewMode === 'grid' ? "flex flex-wrap gap-1" : "space-y-1"} w-full origin-top`}>
|
<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 => (
|
{sortedDocuments.map(doc => (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
key={`${doc.type}-${doc.id}`}
|
key={`${doc.type}-${doc.id}`}
|
||||||
|
|
@ -139,4 +146,4 @@ export function DocumentFolder({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -345,7 +345,13 @@ export function DocumentList() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={viewMode === 'grid' ? "flex flex-wrap gap-1 w-full" : "space-y-1 w-full"}>
|
<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 => (
|
{folders.map(folder => (
|
||||||
<DocumentFolder
|
<DocumentFolder
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
|
||||||
import { Button } from '@headlessui/react';
|
import { Button } from '@headlessui/react';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
import { DocumentListDocument } from '@/types/documents';
|
import { DocumentListDocument } from '@/types/documents';
|
||||||
|
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||||
|
|
||||||
interface DocumentListItemProps {
|
interface DocumentListItemProps {
|
||||||
doc: DocumentListDocument;
|
doc: DocumentListDocument;
|
||||||
|
|
@ -52,47 +53,108 @@ export function DocumentListItem({
|
||||||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||||
aria-busy={loading}
|
aria-busy={loading}
|
||||||
className={`
|
className={
|
||||||
${viewMode === 'grid' ? 'flex-auto min-w-[200px] max-w-full' : 'w-full'} group
|
viewMode === 'grid'
|
||||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
? `
|
||||||
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
flex w-full min-w-0 flex-col
|
||||||
border border-offbase rounded-md p-1
|
group border border-offbase rounded-md overflow-hidden
|
||||||
transition-colors duration-150 relative
|
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
|
||||||
|
`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center w-full">
|
{viewMode === 'grid' ? (
|
||||||
<Link
|
<>
|
||||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
<Link
|
||||||
draggable={false}
|
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||||
className="document-link flex items-center align-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
|
draggable={false}
|
||||||
onClick={handleDocumentClick}
|
className="block"
|
||||||
>
|
aria-label="Open document preview"
|
||||||
<div className="flex-shrink-0">
|
onClick={handleDocumentClick}
|
||||||
{doc.type === 'pdf' ? (
|
>
|
||||||
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
<DocumentPreview doc={doc} />
|
||||||
) : doc.type === 'epub' ? (
|
</Link>
|
||||||
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
<div className="flex items-center w-full px-1.5 py-1.5">
|
||||||
) : (
|
<Link
|
||||||
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||||
)}
|
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>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(doc)}
|
||||||
|
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
|
||||||
|
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 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="flex items-center w-full">
|
||||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
<Link
|
||||||
</p>
|
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||||
</div>
|
draggable={false}
|
||||||
</Link>
|
className="document-link flex items-center align-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
|
||||||
<Button
|
onClick={handleDocumentClick}
|
||||||
onClick={() => onDelete(doc)}
|
>
|
||||||
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
|
<div className="flex-shrink-0">
|
||||||
aria-label="Delete document"
|
{doc.type === 'pdf' ? (
|
||||||
>
|
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
) : doc.type === 'epub' ? (
|
||||||
<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" />
|
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
||||||
</svg>
|
) : (
|
||||||
</Button>
|
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
||||||
</div>
|
)}
|
||||||
|
</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>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(doc)}
|
||||||
|
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
235
src/components/doclist/DocumentPreview.tsx
Normal file
235
src/components/doclist/DocumentPreview.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { DocumentListDocument } from '@/types/documents';
|
||||||
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { db } from '@/lib/dexie';
|
||||||
|
import {
|
||||||
|
extractEpubCoverToDataUrl,
|
||||||
|
extractRawTextSnippet,
|
||||||
|
renderPdfFirstPageToDataUrl,
|
||||||
|
} from '@/lib/documentPreview';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface DocumentPreviewProps {
|
||||||
|
doc: DocumentListDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePreviewCache = new Map<string, string>();
|
||||||
|
const textPreviewCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
||||||
|
const isPDF = doc.type === 'pdf';
|
||||||
|
const isEPUB = doc.type === 'epub';
|
||||||
|
const isHTML = doc.type === 'html';
|
||||||
|
const lowerName = doc.name.toLowerCase();
|
||||||
|
const isTxtFile = isHTML && lowerName.endsWith('.txt');
|
||||||
|
const isMarkdownFile =
|
||||||
|
isHTML &&
|
||||||
|
(lowerName.endsWith('.md') ||
|
||||||
|
lowerName.endsWith('.markdown') ||
|
||||||
|
lowerName.endsWith('.mdown') ||
|
||||||
|
lowerName.endsWith('.mkd'));
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [textPreview, setTextPreview] = useState<string | null>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
const previewKey = useMemo(() => `${doc.type}:${doc.id}`, [doc.id, doc.type]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const cachedImage = imagePreviewCache.get(previewKey);
|
||||||
|
if (cachedImage) {
|
||||||
|
setImagePreview(cachedImage);
|
||||||
|
setTextPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedText = textPreviewCache.get(previewKey);
|
||||||
|
if (cachedText) {
|
||||||
|
setTextPreview(cachedText);
|
||||||
|
setImagePreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const targetWidth = 240;
|
||||||
|
|
||||||
|
if (doc.type === 'pdf') {
|
||||||
|
const pdfDoc = await db['pdf-documents'].get(doc.id);
|
||||||
|
if (!pdfDoc?.data) return;
|
||||||
|
const dataUrl = await renderPdfFirstPageToDataUrl(pdfDoc.data, targetWidth);
|
||||||
|
if (cancelled) return;
|
||||||
|
imagePreviewCache.set(previewKey, dataUrl);
|
||||||
|
setImagePreview(dataUrl);
|
||||||
|
setTextPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.type === 'epub') {
|
||||||
|
const epubDoc = await db['epub-documents'].get(doc.id);
|
||||||
|
if (!epubDoc?.data) return;
|
||||||
|
const cover = await extractEpubCoverToDataUrl(epubDoc.data, targetWidth);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (cover) {
|
||||||
|
imagePreviewCache.set(previewKey, cover);
|
||||||
|
setImagePreview(cover);
|
||||||
|
setTextPreview(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.type === 'html') {
|
||||||
|
const htmlDoc = await db['html-documents'].get(doc.id);
|
||||||
|
if (cancelled) return;
|
||||||
|
const snippet = extractRawTextSnippet(htmlDoc?.data ?? '');
|
||||||
|
textPreviewCache.set(previewKey, snippet);
|
||||||
|
setTextPreview(snippet);
|
||||||
|
setImagePreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall back to icon
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc.id, doc.type, isVisible, previewKey]);
|
||||||
|
|
||||||
|
const gradientClass = isPDF
|
||||||
|
? 'from-red-500/80 via-red-400/60 to-red-600/80'
|
||||||
|
: isEPUB
|
||||||
|
? 'from-blue-500/80 via-blue-400/60 to-blue-600/80'
|
||||||
|
: isHTML
|
||||||
|
? 'from-violet-500/80 via-violet-400/60 to-violet-600/80'
|
||||||
|
: 'from-slate-500/80 via-slate-400/60 to-slate-600/80';
|
||||||
|
|
||||||
|
const Icon = isPDF ? PDFIcon : isEPUB ? EPUBIcon : FileIcon;
|
||||||
|
|
||||||
|
const typeLabel = isPDF
|
||||||
|
? 'PDF'
|
||||||
|
: isEPUB
|
||||||
|
? 'EPUB'
|
||||||
|
: isHTML
|
||||||
|
? isTxtFile
|
||||||
|
? 'TXT'
|
||||||
|
: isMarkdownFile
|
||||||
|
? 'MD'
|
||||||
|
: 'TEXT'
|
||||||
|
: 'FILE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative w-full aspect-[3/4] overflow-hidden rounded-t-md bg-base"
|
||||||
|
>
|
||||||
|
{imagePreview ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt={`${doc.name} preview`}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/35 via-black/0 to-black/15" />
|
||||||
|
</>
|
||||||
|
) : textPreview ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-50 to-slate-200" />
|
||||||
|
<div className="absolute inset-0 opacity-[0.06] bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,1)_1px,transparent_0)] [background-size:12px_12px]" />
|
||||||
|
<div className="relative z-10 h-full w-full p-2 flex flex-col">
|
||||||
|
<div className="mt-auto rounded-md bg-white/70 backdrop-blur-[1px] shadow-sm ring-1 ring-black/5 p-2.5 max-h-[70%] overflow-hidden">
|
||||||
|
{isTxtFile ? (
|
||||||
|
<pre className="text-[10px] sm:text-[11px] leading-snug text-slate-900 whitespace-pre-wrap font-mono">
|
||||||
|
{textPreview}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] sm:text-[11px] leading-snug text-slate-900 break-words [overflow-wrap:anywhere]">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
p: (props) => <p className="m-0" {...props} />,
|
||||||
|
h1: (props) => <h1 className="m-0 font-semibold text-[11px]" {...props} />,
|
||||||
|
h2: (props) => <h2 className="m-0 font-semibold text-[11px]" {...props} />,
|
||||||
|
h3: (props) => <h3 className="m-0 font-semibold text-[11px]" {...props} />,
|
||||||
|
h4: (props) => <h4 className="m-0 font-semibold text-[11px]" {...props} />,
|
||||||
|
h5: (props) => <h5 className="m-0 font-semibold text-[11px]" {...props} />,
|
||||||
|
h6: (props) => <h6 className="m-0 font-semibold text-[11px]" {...props} />,
|
||||||
|
ul: (props) => <ul className="m-0 pl-4" {...props} />,
|
||||||
|
ol: (props) => <ol className="m-0 pl-4" {...props} />,
|
||||||
|
li: (props) => <li className="my-0" {...props} />,
|
||||||
|
a: ({ children }) => <span>{children}</span>,
|
||||||
|
img: () => null,
|
||||||
|
blockquote: (props) => (
|
||||||
|
<blockquote className="m-0 pl-2 border-l-2 border-slate-300 text-slate-700" {...props} />
|
||||||
|
),
|
||||||
|
code: (props) => (
|
||||||
|
<code
|
||||||
|
className="font-mono text-[10px] bg-slate-900/5 rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
pre: (props) => (
|
||||||
|
<pre className="m-0 font-mono text-[10px] whitespace-pre-wrap break-words [overflow-wrap:anywhere]" {...props} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{textPreview}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`absolute inset-0 bg-gradient-to-br ${gradientClass}`} />
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-center h-full gap-2 px-2 text-white">
|
||||||
|
<Icon className="w-10 h-10 sm:w-12 sm:h-12 drop-shadow-md" />
|
||||||
|
<span className="text-[10px] sm:text-[11px] tracking-wide uppercase font-semibold opacity-90">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute left-1 top-1 z-20 rounded bg-black/45 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-white">
|
||||||
|
{isGenerating ? '…' : typeLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/lib/documentPreview.ts
Normal file
184
src/lib/documentPreview.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { pdfjs } from 'react-pdf';
|
||||||
|
import ePub from 'epubjs';
|
||||||
|
|
||||||
|
function shouldUseLegacyPdfWorker(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const ua = window.navigator.userAgent;
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
|
||||||
|
if (!isSafari) return false;
|
||||||
|
const match = ua.match(/Version\/(\d+)/i);
|
||||||
|
if (!match?.[1]) return true;
|
||||||
|
const version = Number.parseInt(match[1], 10);
|
||||||
|
return Number.isFinite(version) ? version < 18 : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePdfWorker(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (pdfjs.GlobalWorkerOptions.workerSrc) return;
|
||||||
|
|
||||||
|
const useLegacy = shouldUseLegacyPdfWorker();
|
||||||
|
const workerSrc = useLegacy
|
||||||
|
? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href
|
||||||
|
: new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href;
|
||||||
|
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerPort = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read blob'));
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scaleImageBlobToDataUrl(blob: Blob, targetWidth: number): Promise<string> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return blobToDataUrl(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof createImageBitmap !== 'function') {
|
||||||
|
return blobToDataUrl(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
try {
|
||||||
|
const scale = targetWidth > 0 ? targetWidth / bitmap.width : 1;
|
||||||
|
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: false });
|
||||||
|
if (!ctx) {
|
||||||
|
return blobToDataUrl(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
return dataUrl;
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdfFirstPageToDataUrl(
|
||||||
|
data: ArrayBuffer,
|
||||||
|
targetWidth: number,
|
||||||
|
): Promise<string> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('PDF thumbnail rendering must run in the browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePdfWorker();
|
||||||
|
|
||||||
|
const loadingTask = pdfjs.getDocument({ data });
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = targetWidth > 0 ? targetWidth / viewport.width : 1;
|
||||||
|
const scaledViewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.floor(scaledViewport.width));
|
||||||
|
canvas.height = Math.max(1, Math.floor(scaledViewport.height));
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: false });
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to create canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const renderTask = page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: scaledViewport,
|
||||||
|
intent: 'display',
|
||||||
|
});
|
||||||
|
await renderTask.promise;
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.82);
|
||||||
|
} finally {
|
||||||
|
await pdf.destroy().catch(() => undefined);
|
||||||
|
await loadingTask.destroy().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractEpubCoverToDataUrl(
|
||||||
|
data: ArrayBuffer,
|
||||||
|
targetWidth: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const book = ePub(data);
|
||||||
|
const opened = book.opened.catch(() => undefined);
|
||||||
|
try {
|
||||||
|
const coverObjectUrl = await book.coverUrl();
|
||||||
|
if (!coverObjectUrl) return null;
|
||||||
|
|
||||||
|
const res = await fetch(coverObjectUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (coverObjectUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(coverObjectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await scaleImageBlobToDataUrl(blob, targetWidth);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
void opened.finally(() => {
|
||||||
|
try {
|
||||||
|
book.destroy();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTextSnippet(source: string, maxChars = 220): string {
|
||||||
|
const strippedHtml = source
|
||||||
|
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ');
|
||||||
|
|
||||||
|
const normalizedMarkdown = strippedHtml
|
||||||
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
|
.replace(/`[^`]+`/g, ' ')
|
||||||
|
.replace(/!\[[^\]]*?\]\([^)]+\)/g, ' ')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/[#>*_~]/g, ' ');
|
||||||
|
|
||||||
|
const normalized = normalizedMarkdown
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.replace(/[ \t]{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const paragraphs = normalized.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
||||||
|
const first = paragraphs[0] ?? normalized;
|
||||||
|
|
||||||
|
if (first.length <= maxChars) return first;
|
||||||
|
return `${first.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRawTextSnippet(source: string, maxChars = 1600): string {
|
||||||
|
const strippedHtml = source
|
||||||
|
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, '');
|
||||||
|
|
||||||
|
const normalized = strippedHtml.replace(/\r\n/g, '\n').trim();
|
||||||
|
|
||||||
|
if (normalized.length <= maxChars) return normalized;
|
||||||
|
return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue