feat(ui): implement better document grid with file previews
Some checks failed
Build and Publish Docker Image / build (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build (push) Has been cancelled
- 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
d5ec96d395
10 changed files with 895 additions and 580 deletions
|
|
@ -35,7 +35,7 @@ RUN pnpm build
|
|||
|
||||
|
||||
# Stage 3: minimal runtime image
|
||||
FROM node:current-alpine AS runner
|
||||
FROM node:lts-alpine AS runner
|
||||
|
||||
# Add runtime OS dependencies:
|
||||
# - 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",
|
||||
"version": "v1.1.0",
|
||||
"version": "v1.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 3003",
|
||||
|
|
@ -13,22 +13,22 @@
|
|||
"@headlessui/react": "^2.2.9",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"cmpstr": "^3.0.4",
|
||||
"compromise": "^14.14.4",
|
||||
"core-js": "^3.46.0",
|
||||
"core-js": "^3.47.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"howler": "^2.2.4",
|
||||
"lru-cache": "^11.2.2",
|
||||
"next": "^15.5.6",
|
||||
"lru-cache": "^11.2.4",
|
||||
"next": "^15.5.9",
|
||||
"openai": "^4.104.0",
|
||||
"pdfjs-dist": "4.8.69",
|
||||
"react": "^19.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dnd": "^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-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -38,16 +38,16 @@
|
|||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20.19.24",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
"eslint-config-next": "^15.5.9",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"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">
|
||||
<SettingsModal />
|
||||
<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>
|
||||
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
|
||||
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
|
||||
|
|
@ -17,7 +17,7 @@ export default function Home() {
|
|||
</div>
|
||||
</section>
|
||||
<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" />
|
||||
<HomeContent />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ export function HomeContent() {
|
|||
|
||||
if (totalDocs === 0) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<DocumentUploader className="py-12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<DocumentList />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function DocumentFolder({
|
|||
if (!draggedDoc || draggedDoc.folderId) return;
|
||||
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="w-full">
|
||||
|
|
@ -104,7 +104,14 @@ export function DocumentFolder({
|
|||
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' ? "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 => (
|
||||
<DocumentListItem
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
|
|
@ -139,4 +146,4 @@ export function DocumentFolder({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,7 +345,13 @@ export function DocumentList() {
|
|||
</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 => (
|
||||
<DocumentFolder
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
|
|||
import { Button } from '@headlessui/react';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { DocumentListDocument } from '@/types/documents';
|
||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||
|
||||
interface DocumentListItemProps {
|
||||
doc: DocumentListDocument;
|
||||
|
|
@ -52,47 +53,108 @@ export function DocumentListItem({
|
|||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||
aria-busy={loading}
|
||||
className={`
|
||||
${viewMode === 'grid' ? 'flex-auto min-w-[200px] max-w-full' : '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
|
||||
`}
|
||||
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
|
||||
`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
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" />
|
||||
)}
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
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={`/${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 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 className="flex items-center w-full">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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