openreader/src/components/doclist/DocumentPreview.tsx
Richard R 7814517b35 style(doclist): enhance preview background with color-mix and radial mask
Update DocumentPreview text preview to use a CSS color-mix radial pattern
with increased opacity and a masked vignette effect. Improves visual
clarity and consistency with design tokens.
2026-06-10 13:44:08 -06:00

364 lines
13 KiB
TypeScript

import { DocumentListDocument } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
documentPreviewFallbackUrl,
getDocumentContentSnippet,
getDocumentPreviewStatus,
} from '@/lib/client/api/documents';
import {
getInMemoryDocumentPreviewUrl,
getPersistedDocumentPreviewUrl,
primeDocumentPreviewCache,
setInMemoryDocumentPreviewUrl,
} from '@/lib/client/cache/previews';
import { formatDocumentSize } from './formatSize';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface DocumentPreviewProps {
doc: DocumentListDocument;
}
const MAX_TEXT_PREVIEW_CACHE = 100;
const textPreviewCache = new Map<string, string>();
/** Read from cache and promote entry to most-recently-used. */
function textPreviewCacheGet(key: string): string | undefined {
const value = textPreviewCache.get(key);
if (value !== undefined) {
// Re-insert to move to end (most-recently-used)
textPreviewCache.delete(key);
textPreviewCache.set(key, value);
}
return value;
}
/** Write to cache, evicting the least-recently-used entry when over the cap. */
function textPreviewCacheSet(key: string, value: string): void {
// If the key already exists, delete first so re-insertion moves it to the end
if (textPreviewCache.has(key)) {
textPreviewCache.delete(key);
}
textPreviewCache.set(key, value);
if (textPreviewCache.size > MAX_TEXT_PREVIEW_CACHE) {
// Map keys iterate in insertion order; first key is the LRU entry
const oldest = textPreviewCache.keys().next().value;
if (oldest !== undefined) textPreviewCache.delete(oldest);
}
}
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 imageRef = useRef<HTMLImageElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [isImageReady, setIsImageReady] = useState(false);
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 = getInMemoryDocumentPreviewUrl(previewKey);
if (cachedImage) {
setImagePreview(cachedImage);
setTextPreview(null);
return;
}
const cachedText = textPreviewCacheGet(previewKey);
if (cachedText) {
setTextPreview(cachedText);
setImagePreview(null);
return;
}
let cancelled = false;
const controller = new AbortController();
const run = async () => {
setIsGenerating(true);
try {
if (doc.type === 'pdf' || doc.type === 'epub') {
const persistedUrl = await getPersistedDocumentPreviewUrl(
doc.id,
Number(doc.lastModified),
previewKey,
);
if (!cancelled && persistedUrl) {
setImagePreview(persistedUrl);
setTextPreview(null);
return;
}
let attempt = 0;
while (!cancelled && attempt < 12) {
const status = await getDocumentPreviewStatus(doc.id, { signal: controller.signal });
if (cancelled) return;
if (status.kind === 'ready') {
const primedUrl = await primeDocumentPreviewCache(
doc.id,
Number(doc.lastModified),
previewKey,
{ signal: controller.signal },
).catch(() => null);
if (cancelled) return;
if (primedUrl) {
setImagePreview(primedUrl);
setTextPreview(null);
return;
}
const fallbackUrl = status.fallbackUrl || documentPreviewFallbackUrl(doc.id);
setInMemoryDocumentPreviewUrl(previewKey, fallbackUrl);
setImagePreview(fallbackUrl);
setTextPreview(null);
return;
}
if (status.status === 'failed') {
return;
}
const waitMs = Math.max(
400,
Math.min(6000, Number.isFinite(status.retryAfterMs) ? status.retryAfterMs : 1500),
);
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, waitMs);
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
attempt += 1;
}
return;
}
if (doc.type === 'html') {
const snippet = await getDocumentContentSnippet(doc.id, {
maxChars: 1600,
maxBytes: 128 * 1024,
signal: controller.signal,
});
if (cancelled) return;
textPreviewCacheSet(previewKey, snippet);
setTextPreview(snippet);
setImagePreview(null);
return;
}
} catch {
// fall back to icon
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
run();
return () => {
cancelled = true;
controller.abort();
};
}, [doc.id, doc.lastModified, doc.type, isVisible, previewKey]);
useEffect(() => {
setIsImageReady(false);
}, [imagePreview]);
// Cached blob/http sources can already be decoded before React's onLoad handler
// runs on remount, leaving opacity at 0. Promote already-complete images.
useEffect(() => {
if (!imagePreview) return;
const img = imageRef.current;
if (!img) return;
if (img.complete && img.naturalWidth > 0) {
setIsImageReady(true);
}
}, [imagePreview]);
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-surface"
>
{imagePreview ? (
<>
<div className={`absolute inset-0 bg-gradient-to-br ${gradientClass}`} />
{!isImageReady ? (
<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>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={imagePreview}
alt={`${doc.name} preview`}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-fast ${isImageReady ? 'opacity-100' : 'opacity-0'}`}
draggable={false}
loading="lazy"
onLoad={() => {
setIsImageReady(true);
}}
onError={() => {
if (!imagePreview) return;
setIsImageReady(false);
const fallback = documentPreviewFallbackUrl(doc.id);
if (imagePreview === fallback) return;
setInMemoryDocumentPreviewUrl(previewKey, fallback);
setImagePreview(fallback);
void primeDocumentPreviewCache(
doc.id,
Number(doc.lastModified),
previewKey,
).catch(() => { });
}}
/>
{isImageReady ? <div className="absolute inset-0 bg-gradient-to-t from-black/35 via-black/0 to-black/15" /> : null}
</>
) : textPreview ? (
<>
<div className="absolute inset-0 bg-gradient-to-br from-surface-solid to-surface-sunken" />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
'radial-gradient(color-mix(in srgb, var(--foreground) 14%, transparent) 1px, transparent 1px)',
backgroundSize: '12px 12px',
opacity: 0.35,
WebkitMaskImage: 'radial-gradient(120% 100% at 50% 40%, #000 55%, transparent 100%)',
maskImage: 'radial-gradient(120% 100% at 50% 40%, #000 55%, transparent 100%)',
}}
/>
<div className="relative z-10 h-full w-full p-2 flex flex-col">
<div className="mb-auto rounded-md bg-surface-solid backdrop-blur-[1px] shadow-elev-1 ring-1 ring-line-soft p-2.5 max-h-[85%] overflow-hidden">
{isTxtFile ? (
<pre className="text-[10px] sm:text-[11px] leading-snug text-foreground whitespace-pre-wrap font-mono">
{textPreview}
</pre>
) : (
<div className="text-[10px] sm:text-[11px] leading-snug text-foreground 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-line text-soft" {...props} />
),
code: (props) => (
<code
className="font-mono text-[10px] bg-surface-sunken 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 bottom-1 z-20 rounded bg-black/45 px-1.5 py-0.5 text-[9px] font-semibold tracking-wide text-white/90">
{isGenerating
? '…'
: `${typeLabel}${formatDocumentSize(doc.size)}`}
</div>
</div>
);
}