feat(ui): implement better document grid with file previews
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:
Richard Roberson 2025-12-12 13:13:49 -07:00
parent 30ade1d79a
commit d5ec96d395
10 changed files with 895 additions and 580 deletions

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

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